From 79f9ef8688efed3f2d185d8a80821d9fb7a580c3 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 31 Mar 2026 22:14:26 +0100 Subject: [PATCH 1/3] [#684] Merge Reader Dashboard into Profile Portfolio tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portfolio tab now includes paginated Trading History with buy/sell badges, storyline titles, USD values — visible for all profiles (public on-chain data) - Donation history (given as reader) gated to own profile only - /reader now redirects to /profile/[address]?tab=portfolio Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/dashboard/reader/page.tsx | 321 +---------------------------- src/app/profile/[address]/page.tsx | 158 +++++++++++++- 2 files changed, 164 insertions(+), 315 deletions(-) diff --git a/src/app/dashboard/reader/page.tsx b/src/app/dashboard/reader/page.tsx index 0532b28e..ef350a62 100644 --- a/src/app/dashboard/reader/page.tsx +++ b/src/app/dashboard/reader/page.tsx @@ -1,79 +1,19 @@ "use client"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import { useAccount } from "wagmi"; -import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; -import { supabase, type Donation, type TradeHistory } from "../../../../lib/supabase"; -import { formatPrice, formatSupply } from "../../../../lib/format"; -import { ReaderPortfolio } from "../../../components/ReaderPortfolio"; -import Link from "next/link"; -import { WriterIdentityClient } from "../../../components/WriterIdentityClient"; -import { formatUnits } from "viem"; import { ConnectWallet } from "../../../components/ConnectWallet"; -import { RESERVE_LABEL, PLOT_TOKEN, STORY_FACTORY, EXPLORER_URL } from "../../../../lib/contracts/constants"; -import { browserClient as publicClient } from "../../../../lib/rpc"; -import { formatUsdValue } from "../../../../lib/usd-price"; -import { usePlotUsdPrice } from "../../../hooks/usePlotUsdPrice"; -import { type Address } from "viem"; -/** Truncate formatUnits output to at most `digits` decimal places */ -function formatTruncated(value: bigint, decimals: number, digits = 6): string { - const raw = formatUnits(value, decimals); - const dot = raw.indexOf("."); - if (dot === -1 || raw.length - dot - 1 <= digits) return raw; - return raw.slice(0, dot + 1 + digits).replace(/0+$/, "").replace(/\.$/, ""); -} - -const PAGE_SIZE = 10; - -export default function ReaderDashboard() { +export default function ReaderRedirect() { + const router = useRouter(); const { address, isConnected } = useAccount(); - const { data: plotUsd } = usePlotUsdPrice(); - - const { - data, - isLoading, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - error, - } = useInfiniteQuery({ - queryKey: ["reader-donations", address], - queryFn: async ({ pageParam = 0 }) => { - if (!supabase) return { rows: [] as Donation[], totalCount: 0 }; - const { data: rows, count, error } = await supabase - .from("donations") - .select("*", { count: "exact" }) - .eq("donor_address", address!.toLowerCase()) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .range(pageParam, pageParam + PAGE_SIZE - 1) - .returns(); - if (error) throw error; - return { rows: rows ?? [], totalCount: count ?? 0 }; - }, - initialPageParam: 0, - getNextPageParam: (_lastPage, allPages) => { - const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); - const totalCount = allPages[0]?.totalCount ?? 0; - return totalFetched < totalCount ? totalFetched : undefined; - }, - enabled: isConnected && !!address, - }); - - // Fetch reserve token decimals dynamically - const { data: reserveDecimals = 18 } = useQuery({ - queryKey: ["reserve-decimals"], - queryFn: async () => { - return publicClient.readContract({ - address: PLOT_TOKEN as Address, - abi: [{ type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint8" }] }] as const, - functionName: "decimals", - }); - }, - }); - const donations = data?.pages.flatMap((p) => p.rows) ?? []; - const totalCount = data?.pages[0]?.totalCount ?? 0; + useEffect(() => { + if (isConnected && address) { + router.replace(`/profile/${address}?tab=portfolio`); + } + }, [isConnected, address, router]); if (!isConnected) { return ( @@ -86,248 +26,9 @@ export default function ReaderDashboard() { ); } - const totalDonated = donations.reduce( - (sum, d) => sum + BigInt(d.amount), - BigInt(0), - ); - - return ( -
-

- Reader Dashboard -

-

- -

- - - - {/* --- Trading History --- */} - - - {/* --- Donation History --- */} -
-

- Donation History -

-

- {totalCount} {totalCount === 1 ? "donation" : "donations"} - {donations.length > 0 && ( - - {" "} - · {formatTruncated(totalDonated, reserveDecimals)} {RESERVE_LABEL} total loaded - - )} -

- - {isLoading &&

Loading...

} - - {error && ( -

- Failed to load donations. Please try again. -

- )} - -
- {donations.map((d) => ( - - ))} - {!isLoading && !error && donations.length === 0 && ( -

- No donations yet. -

- )} -
- - {hasNextPage && ( - - )} -
-
- ); -} - -function DonationRow({ donation, decimals }: { donation: Donation; decimals: number }) { return ( -
-
- - Story #{donation.storyline_id} - - {donation.block_timestamp && ( - - )} -
-
- - {formatTruncated(BigInt(donation.amount), decimals)} {RESERVE_LABEL} - - {donation.tx_hash && ( - - ↗ - - )} -
+
+

Redirecting to profile...

); } - -const TRADE_PAGE_SIZE = 10; - -function TradingHistory({ address, plotUsd }: { address: string; plotUsd?: number | null }) { - const { - data, - isLoading, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - } = useInfiniteQuery({ - queryKey: ["reader-trades", address], - queryFn: async ({ pageParam = 0 }) => { - if (!supabase) return { rows: [] as TradeHistory[], totalCount: 0 }; - const { data: rows, count } = await supabase - .from("trade_history") - .select("*", { count: "exact" }) - .eq("user_address", address.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .range(pageParam, pageParam + TRADE_PAGE_SIZE - 1) - .returns(); - return { rows: rows ?? [], totalCount: count ?? 0 }; - }, - initialPageParam: 0, - getNextPageParam: (_lastPage, allPages) => { - const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); - const totalCount = allPages[0]?.totalCount ?? 0; - return totalFetched < totalCount ? totalFetched : undefined; - }, - }); - - const trades = data?.pages.flatMap((p) => p.rows) ?? []; - const totalCount = data?.pages[0]?.totalCount ?? 0; - - // Fetch storyline titles for displayed trades - const storylineIds = [...new Set(trades.map((t) => t.storyline_id))]; - const { data: storylineTitles } = useQuery({ - queryKey: ["storyline-titles", storylineIds.join(",")], - queryFn: async () => { - if (!supabase || storylineIds.length === 0) return {} as Record; - const { data: rows } = await supabase - .from("storylines") - .select("storyline_id, title") - .in("storyline_id", storylineIds); - const map: Record = {}; - for (const r of rows ?? []) map[r.storyline_id] = r.title; - return map; - }, - enabled: storylineIds.length > 0, - }); - - return ( -
-

Trading History

-

- {totalCount} {totalCount === 1 ? "trade" : "trades"} -

- - {isLoading &&

Loading...

} - -
- {trades.map((t) => { - const isBuy = t.event_type === "mint"; - const title = storylineTitles?.[t.storyline_id]; - const tokenCount = t.price_per_token > 0 ? t.reserve_amount / t.price_per_token : 0; - return ( -
-
-
-
- - {isBuy ? "Buy" : "Sell"} - - - {title || `Story #${t.storyline_id}`} - -
-
- {tokenCount > 0 && ( - {formatSupply(tokenCount)} tokens - )} - {t.block_timestamp && ( - - )} -
-
-
- - {formatPrice(t.reserve_amount)} {RESERVE_LABEL} - {plotUsd && ( - - (≈ {formatUsdValue(t.reserve_amount * plotUsd)}) - - )} - - {t.tx_hash && ( - - ↗ - - )} -
-
-
- ); - })} - {!isLoading && trades.length === 0 && ( -

- No trades yet. -

- )} -
- - {hasNextPage && ( - - )} -
- ); -} diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index b335a136..0318435c 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -10,7 +10,7 @@ import { supabase, type Storyline, type Donation, type TradeHistory, type User } import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } from "../../../../lib/contracts/constants"; import { getFullUserProfile } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; -import { formatPrice } from "../../../../lib/format"; +import { formatPrice, formatSupply } from "../../../../lib/format"; import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo } from "../../../../lib/price"; import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; @@ -169,7 +169,7 @@ export default function ProfilePage() { connectedAddress={connectedAddress ?? null} /> )} - {tab === "portfolio" && } + {tab === "portfolio" && } {tab === "activity" && }
); @@ -1139,7 +1139,7 @@ interface PortfolioHolding { lastTraded: string | null; } -function PortfolioTab({ address }: { address: string }) { +function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile: boolean }) { const { data: plotUsd } = usePlotUsdPrice(); // Fetch on-chain token holdings @@ -1401,8 +1401,8 @@ function PortfolioTab({ address }: { address: string }) { )} - {/* Donations given as reader */} - {hasDonationsGiven && ( + {/* Donations given as reader — own profile only */} + {isOwnProfile && hasDonationsGiven && (

Donations Given @@ -1452,6 +1452,154 @@ function PortfolioTab({ address }: { address: string }) {

)} + + {/* Trading History — public data, shown for all profiles */} + + + ); +} + +// --------------------------------------------------------------------------- +// Portfolio Trading History — paginated trades +// --------------------------------------------------------------------------- + +const TRADE_PAGE_SIZE = 10; + +function PortfolioTradingHistory({ address, plotUsd }: { address: string; plotUsd?: number | null }) { + const { + data, + isLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery({ + queryKey: ["portfolio-trades", address], + queryFn: async ({ pageParam = 0 }) => { + if (!supabase) return { rows: [] as TradeHistory[], totalCount: 0 }; + const { data: rows, count } = await supabase + .from("trade_history") + .select("*", { count: "exact" }) + .eq("user_address", address.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .range(pageParam, pageParam + TRADE_PAGE_SIZE - 1) + .returns(); + return { rows: rows ?? [], totalCount: count ?? 0 }; + }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => { + const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); + const totalCount = allPages[0]?.totalCount ?? 0; + return totalFetched < totalCount ? totalFetched : undefined; + }, + }); + + const trades = data?.pages.flatMap((p) => p.rows) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; + + // Fetch storyline titles for displayed trades + const storylineIds = [...new Set(trades.map((t) => t.storyline_id))]; + const { data: storylineTitles } = useQuery({ + queryKey: ["storyline-titles", storylineIds.join(",")], + queryFn: async () => { + if (!supabase || storylineIds.length === 0) return {} as Record; + const { data: rows } = await supabase + .from("storylines") + .select("storyline_id, title") + .in("storyline_id", storylineIds); + const map: Record = {}; + for (const r of rows ?? []) map[r.storyline_id] = r.title; + return map; + }, + enabled: storylineIds.length > 0, + }); + + if (isLoading) return

Loading trades...

; + if (trades.length === 0) return null; + + return ( +
+

+ Trading History + + {totalCount} {totalCount === 1 ? "trade" : "trades"} + +

+ +
+ {trades.map((t) => { + const isBuy = t.event_type === "mint"; + const title = storylineTitles?.[t.storyline_id]; + const tokenCount = t.price_per_token > 0 ? t.reserve_amount / t.price_per_token : 0; + return ( +
+
+
+
+ + {isBuy ? "Buy" : "Sell"} + + + {title || `Story #${t.storyline_id}`} + +
+
+ {tokenCount > 0 && ( + {formatSupply(tokenCount)} tokens + )} + {t.block_timestamp && ( + + )} +
+
+
+ + {formatPrice(t.reserve_amount)} {RESERVE_LABEL} + {plotUsd && ( + + (≈ {formatUsdValue(t.reserve_amount * plotUsd)}) + + )} + + {t.tx_hash && ( + + ↗ + + )} +
+
+
+ ); + })} +
+ + {hasNextPage && ( + + )}
); } From 25bd010336807578f5de4c56eaabfee9ac14dc9a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 31 Mar 2026 22:18:40 +0100 Subject: [PATCH 2/3] [#684] Fix review feedback: add 24h price change, best pick, paginate donations - Portfolio holdings now include 24h price change per token and best pick summary - Uses get24hPriceChange + getTokenTVL for reserve decimals (matches ReaderPortfolio) - Donations given now uses paginated useInfiniteQuery instead of capped query Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 137 ++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 34 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 0318435c..5c1b14e4 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -11,7 +11,7 @@ import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } fro import { getFullUserProfile } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; import { formatPrice, formatSupply } from "../../../../lib/format"; -import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo } from "../../../../lib/price"; +import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo, get24hPriceChange, getTokenTVL } from "../../../../lib/price"; import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; @@ -1137,6 +1137,8 @@ interface PortfolioHolding { value: bigint; entryPrice: number | null; lastTraded: string | null; + priceChange: number | null; + reserveDecimals: number; } function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile: boolean }) { @@ -1175,18 +1177,24 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile .filter((h) => h.balance.status === "success" && (h.balance.result as bigint) > BigInt(0)); if (held.length === 0) return []; - // Fetch prices for held tokens + // Fetch prices, 24h change, and TVL for held tokens const results = await Promise.all( held.map(async ({ sl, balance: balResult }): Promise => { + const tokenAddr = sl.token_address as Address; const balance = balResult.result as bigint; try { - const price = await browserClient.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [sl.token_address as Address], - }); + const [price, priceChangeResult, tvlResult] = await Promise.all([ + browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddr], + }), + get24hPriceChange(tokenAddr, browserClient).catch(() => null), + getTokenTVL(tokenAddr, browserClient).catch(() => null), + ]); const priceBI = BigInt(price); + const reserveDecimals = tvlResult?.decimals ?? 18; const value = (balance * priceBI) / BigInt(10 ** 18); // Derive entry price from first mint in trade_history @@ -1218,7 +1226,12 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile } } - return { storyline: sl, balance, price: priceBI, value, entryPrice, lastTraded }; + return { + storyline: sl, balance, price: priceBI, value, + entryPrice, lastTraded, + priceChange: priceChangeResult?.changePercent ?? null, + reserveDecimals, + }; } catch { return null; } @@ -1238,22 +1251,38 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile staleTime: 60000, }); - // Donation history (given as reader) - const { data: donationsGiven = [], isLoading: donGivenLoading } = useQuery({ + // Donation history (given as reader) — paginated + const DONATION_PAGE = 10; + const { + data: donationPages, + isLoading: donGivenLoading, + isFetchingNextPage: donFetchingNext, + fetchNextPage: donFetchNext, + hasNextPage: donHasNext, + } = useInfiniteQuery({ queryKey: ["profile-donations-given", address], - queryFn: async () => { - if (!supabase) return []; - const { data } = await supabase + queryFn: async ({ pageParam = 0 }) => { + if (!supabase) return { rows: [] as Donation[], totalCount: 0 }; + const { data: rows, count } = await supabase .from("donations") - .select("*") + .select("*", { count: "exact" }) .eq("donor_address", address) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) - .limit(20) + .range(pageParam, pageParam + DONATION_PAGE - 1) .returns(); - return data ?? []; + return { rows: rows ?? [], totalCount: count ?? 0 }; + }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => { + const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); + const totalCount = allPages[0]?.totalCount ?? 0; + return totalFetched < totalCount ? totalFetched : undefined; }, + enabled: isOwnProfile, }); + const donationsGiven = donationPages?.pages.flatMap((p) => p.rows) ?? []; + const donationTotalCount = donationPages?.pages[0]?.totalCount ?? 0; // Aggregate donations received as writer const { data: donationsReceived, isLoading: donRecvLoading } = useQuery({ @@ -1303,6 +1332,12 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile } const totalValue = holdings?.reduce((sum, h) => sum + h.value, BigInt(0)) ?? BigInt(0); + const reserveDecimals = holdings && holdings.length > 0 ? holdings[0].reserveDecimals : 18; + const bestPick = holdings && holdings.length > 0 + ? holdings.reduce((best, h) => + (h.priceChange ?? -Infinity) > (best.priceChange ?? -Infinity) ? h : best + ) + : null; const totalDonated = donationsGiven.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); return ( @@ -1311,18 +1346,35 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile {hasHoldings && ( <>
-

Portfolio Value

- - {formatPrice(formatUnits(totalValue, 18))} {RESERVE_LABEL} - - {plotUsd && ( - - ≈ {formatUsdValue(Number(formatUnits(totalValue, 18)) * plotUsd)} - - )} - - across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"} - +
+
+ Portfolio Value + + {formatPrice(formatUnits(totalValue, reserveDecimals))} {RESERVE_LABEL} + + {plotUsd && ( + + ≈ {formatUsdValue(Number(formatUnits(totalValue, reserveDecimals)) * plotUsd)} + + )} + + across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"} + +
+ {bestPick && bestPick.priceChange !== null && ( +
+ Best Pick (24h) + + {bestPick.storyline.title.slice(0, 20)} + {bestPick.storyline.title.length > 20 ? "..." : ""}{" "} + = 0 ? "text-accent" : "text-error"}> + {bestPick.priceChange >= 0 ? "+" : ""} + {bestPick.priceChange.toFixed(1)}% + + +
+ )} +
{/* Token holdings */} @@ -1349,13 +1401,18 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile
- {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} + {formatPrice(formatUnits(h.value, h.reserveDecimals))} {RESERVE_LABEL} {plotUsd && ( - ({formatUsdValue(Number(formatUnits(h.value, 18)) * plotUsd)}) + ({formatUsdValue(Number(formatUnits(h.value, h.reserveDecimals)) * plotUsd)}) )} + {h.priceChange !== null && ( +
= 0 ? "text-accent" : "text-error"}`}> + {h.priceChange >= 0 ? "+" : ""}{h.priceChange.toFixed(1)}% +
+ )}
@@ -1401,14 +1458,17 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile
)} - {/* Donations given as reader — own profile only */} + {/* Donations given as reader — own profile only, paginated */} {isOwnProfile && hasDonationsGiven && (

Donations Given - {totalDonated > BigInt(0) && ( + {donationTotalCount > 0 && ( - {formatPrice(formatUnits(totalDonated, 18))} {RESERVE_LABEL} total + {donationTotalCount} {donationTotalCount === 1 ? "donation" : "donations"} + {totalDonated > BigInt(0) && ( + <> · {formatPrice(formatUnits(totalDonated, 18))} {RESERVE_LABEL} total loaded + )} )}

@@ -1450,6 +1510,15 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile
))} + {donHasNext && ( + + )} )} From b521d5b241c9098877ec787ed8418b45c4ac706f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 31 Mar 2026 22:20:47 +0100 Subject: [PATCH 3/3] [#684] Remove early return so trading history always renders Profiles with trades but no holdings/donations were hitting the empty state early return, hiding the PortfolioTradingHistory section. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 5c1b14e4..c3ec1daf 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1318,18 +1318,6 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile const hasHoldings = holdings && holdings.length > 0; const hasDonationsGiven = donationsGiven.length > 0; const hasDonationsReceived = donationsReceived && donationsReceived.count > 0; - const hasAny = hasHoldings || hasDonationsGiven || hasDonationsReceived; - - if (!hasAny) { - return ( -
-

No holdings or donations yet.

-

- This address hasn't purchased any storyline tokens or made donations. -

-
- ); - } const totalValue = holdings?.reduce((sum, h) => sum + h.value, BigInt(0)) ?? BigInt(0); const reserveDecimals = holdings && holdings.length > 0 ? holdings[0].reserveDecimals : 18;