From 6a57efc481bb8519b105f939beb549d60d645ddc Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:22:11 +0000 Subject: [PATCH 1/5] [#29] Add trading stats to writer and reader dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #29 - P5-7a: WriterTradingStats component — shows total donations received, per-story token supply (minting volume), tokens minted breakdown - P5-7b: ReaderPortfolio component — queries balanceOf for all storyline tokens, shows portfolio value (balance * price per token from getReserveForToken), best-performing pick - Replaces Phase 5 placeholder in reader dashboard - All addresses from lib/contracts/constants.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/dashboard/reader/page.tsx | 12 +- src/app/dashboard/writer/page.tsx | 13 +++ src/components/ReaderPortfolio.tsx | 153 ++++++++++++++++++++++++++ src/components/WriterTradingStats.tsx | 111 +++++++++++++++++++ 4 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 src/components/ReaderPortfolio.tsx create mode 100644 src/components/WriterTradingStats.tsx diff --git a/src/app/dashboard/reader/page.tsx b/src/app/dashboard/reader/page.tsx index 61a0da82..56a17c9e 100644 --- a/src/app/dashboard/reader/page.tsx +++ b/src/app/dashboard/reader/page.tsx @@ -5,7 +5,8 @@ import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { supabase, type Donation } from "../../../../lib/supabase"; import { ConnectWallet } from "../../../components/ConnectWallet"; -import { formatUnits } from "viem"; +import { ReaderPortfolio } from "../../../components/ReaderPortfolio"; +import { formatUnits, type Address } from "viem"; const PAGE_SIZE = 50; @@ -76,13 +77,8 @@ export default function ReaderDashboard() { Reader Dashboard - {/* --- Portfolio section (Phase 5) --- */} -
-

Portfolio

-

- Token holdings and portfolio value available after Phase 5 (P5-7b). -

-
+ {/* --- Portfolio section --- */} + {/* --- Donation History --- */}
diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index 9c334d10..f492e7f7 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -6,6 +6,7 @@ import { supabase, type Storyline } from "../../../../lib/supabase"; import { ConnectWallet } from "../../../components/ConnectWallet"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; import { ClaimRoyalties } from "../../../components/ClaimRoyalties"; +import { WriterTradingStats } from "../../../components/WriterTradingStats"; import Link from "next/link"; import { type Address } from "viem"; @@ -62,6 +63,18 @@ export default function WriterDashboard() {

)} + {isConnected && address && storylines.length > 0 && ( + s.token_address) + .map((s) => ({ + storylineId: s.storyline_id, + tokenAddress: s.token_address as Address, + }))} + /> + )} +
{storylines.map((s) => ( diff --git a/src/components/ReaderPortfolio.tsx b/src/components/ReaderPortfolio.tsx new file mode 100644 index 00000000..c1f99b6a --- /dev/null +++ b/src/components/ReaderPortfolio.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatUnits, type Address } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { erc20Abi, mcv2BondAbi } from "../../lib/price"; +import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; +import { supabase, type Storyline } from "../../lib/supabase"; + +interface ReaderPortfolioProps { + readerAddress: Address; +} + +interface Holding { + storylineId: number; + title: string; + balance: bigint; + pricePerToken: bigint; + value: bigint; +} + +export function ReaderPortfolio({ readerAddress }: ReaderPortfolioProps) { + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + + const { data: holdings, isLoading } = useQuery({ + queryKey: ["reader-portfolio", readerAddress], + queryFn: async () => { + if (!supabase) return []; + + // Fetch all storylines with token addresses + const { data: storylines } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .returns(); + + if (!storylines) return []; + + const withTokens = storylines.filter((s) => s.token_address); + const results: Holding[] = []; + + // Check balance for each token + for (const s of withTokens) { + try { + const balance = await publicClient.readContract({ + address: s.token_address as Address, + abi: erc20Abi, + functionName: "balanceOf", + args: [readerAddress], + }); + + if (balance > BigInt(0)) { + // Get current price for value calculation + let pricePerToken = BigInt(0); + try { + const oneToken = BigInt(10 ** 18); + pricePerToken = await publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getReserveForToken", + args: [s.token_address as Address, oneToken], + }); + } catch { + // Price unavailable + } + + const value = + pricePerToken > BigInt(0) + ? (balance * pricePerToken) / BigInt(10 ** 18) + : BigInt(0); + + results.push({ + storylineId: s.storyline_id, + title: s.title, + balance, + pricePerToken, + value, + }); + } + } catch { + // Skip tokens that fail + } + } + + return results; + }, + }); + + const allHoldings = holdings ?? []; + const totalValue = allHoldings.reduce((sum, h) => sum + h.value, BigInt(0)); + const bestPick = + allHoldings.length > 0 + ? allHoldings.reduce((best, h) => (h.value > best.value ? h : best)) + : null; + + return ( +
+

Portfolio

+ + {isLoading && ( +

Loading holdings...

+ )} + + {!isLoading && allHoldings.length === 0 && ( +

+ No token holdings found. +

+ )} + + {allHoldings.length > 0 && ( + <> +
+
+ + Portfolio Value + + + {formatUnits(totalValue, 18)} {reserveLabel} + +
+ {bestPick && ( +
+ + Best Pick + + {bestPick.title} +
+ )} +
+ +
+ {allHoldings.map((h) => ( +
+ {h.title} + + {formatUnits(h.balance, 18)} tokens + {h.value > BigInt(0) && ( + + ({formatUnits(h.value, 18)} {reserveLabel}) + + )} + +
+ ))} +
+ + )} +
+ ); +} diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx new file mode 100644 index 00000000..dbcb34bb --- /dev/null +++ b/src/components/WriterTradingStats.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatUnits, type Address } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { erc20Abi } from "../../lib/price"; +import { IS_TESTNET } from "../../lib/contracts/constants"; +import { supabase, type Donation } from "../../lib/supabase"; + +interface WriterTradingStatsProps { + writerAddress: Address; + storylineTokens: { storylineId: number; tokenAddress: Address }[]; +} + +interface StoryStats { + storylineId: number; + totalSupply: bigint; +} + +export function WriterTradingStats({ + writerAddress, + storylineTokens, +}: WriterTradingStatsProps) { + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + + // Fetch total donations received + const { data: totalDonations } = useQuery({ + queryKey: ["writer-total-donations", writerAddress], + queryFn: async () => { + if (!supabase) return BigInt(0); + // Query donations for storylines owned by this writer + const { data } = await supabase + .from("donations") + .select("amount") + .returns[]>(); + if (!data) return BigInt(0); + return data.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + }, + }); + + // Fetch per-story trading volume (totalSupply) and holder count + const { data: storyStats } = useQuery({ + queryKey: ["writer-story-stats", storylineTokens.map((t) => t.tokenAddress)], + queryFn: async () => { + const results: StoryStats[] = []; + for (const t of storylineTokens) { + try { + const supply = await publicClient.readContract({ + address: t.tokenAddress, + abi: erc20Abi, + functionName: "totalSupply", + }); + results.push({ storylineId: t.storylineId, totalSupply: supply }); + } catch { + results.push({ storylineId: t.storylineId, totalSupply: BigInt(0) }); + } + } + return results; + }, + enabled: storylineTokens.length > 0, + }); + + if (storylineTokens.length === 0) return null; + + const totalVolume = (storyStats ?? []).reduce( + (sum, s) => sum + s.totalSupply, + BigInt(0), + ); + + return ( +
+

Trading Stats

+
+
+ + Total Donations + + + {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel} + +
+
+ + Total Tokens Minted + + + {formatUnits(totalVolume, 18)} + +
+
+ + {storyStats && storyStats.length > 0 && ( +
+ {storyStats + .filter((s) => s.totalSupply > BigInt(0)) + .map((s) => ( +
+ Story #{s.storylineId} + + {formatUnits(s.totalSupply, 18)} tokens minted + +
+ ))} +
+ )} +
+ ); +} From 9f0c0fa8b2876ca101739fe32e4bf77120a0d1a4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:24:39 +0000 Subject: [PATCH 2/5] [#29] Fix donations scope and add holder count - Scope donations query to writer's storyline IDs only - Add unique holder count per storyline (unique donors as proxy) - Display total holders in summary and per-story breakdown Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WriterTradingStats.tsx | 62 +++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index dbcb34bb..9b374b3d 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -15,6 +15,7 @@ interface WriterTradingStatsProps { interface StoryStats { storylineId: number; totalSupply: bigint; + holderCount: number; } export function WriterTradingStats({ @@ -23,25 +24,49 @@ export function WriterTradingStats({ }: WriterTradingStatsProps) { const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; - // Fetch total donations received + const storyIds = storylineTokens.map((t) => t.storylineId); + + // Fetch total donations received for this writer's storylines only const { data: totalDonations } = useQuery({ - queryKey: ["writer-total-donations", writerAddress], + queryKey: ["writer-total-donations", writerAddress, storyIds], queryFn: async () => { - if (!supabase) return BigInt(0); - // Query donations for storylines owned by this writer + if (!supabase || storyIds.length === 0) return BigInt(0); const { data } = await supabase .from("donations") .select("amount") + .in("storyline_id", storyIds) .returns[]>(); if (!data) return BigInt(0); return data.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); }, + enabled: storyIds.length > 0, }); // Fetch per-story trading volume (totalSupply) and holder count const { data: storyStats } = useQuery({ queryKey: ["writer-story-stats", storylineTokens.map((t) => t.tokenAddress)], queryFn: async () => { + // Get unique donor counts per storyline as holder proxy + const donorCounts = new Map(); + if (supabase && storyIds.length > 0) { + const { data: donors } = await supabase + .from("donations") + .select("storyline_id, donor_address") + .in("storyline_id", storyIds) + .returns[]>(); + if (donors) { + const perStory = new Map>(); + for (const d of donors) { + const set = perStory.get(d.storyline_id) ?? new Set(); + set.add(d.donor_address); + perStory.set(d.storyline_id, set); + } + for (const [id, set] of perStory) { + donorCounts.set(id, set.size); + } + } + } + const results: StoryStats[] = []; for (const t of storylineTokens) { try { @@ -50,9 +75,17 @@ export function WriterTradingStats({ abi: erc20Abi, functionName: "totalSupply", }); - results.push({ storylineId: t.storylineId, totalSupply: supply }); + results.push({ + storylineId: t.storylineId, + totalSupply: supply, + holderCount: donorCounts.get(t.storylineId) ?? 0, + }); } catch { - results.push({ storylineId: t.storylineId, totalSupply: BigInt(0) }); + results.push({ + storylineId: t.storylineId, + totalSupply: BigInt(0), + holderCount: donorCounts.get(t.storylineId) ?? 0, + }); } } return results; @@ -70,7 +103,7 @@ export function WriterTradingStats({ return (

Trading Stats

-
+
Total Donations @@ -87,6 +120,14 @@ export function WriterTradingStats({ {formatUnits(totalVolume, 18)}
+
+ + Unique Holders + + + {(storyStats ?? []).reduce((sum, s) => sum + s.holderCount, 0)} + +
{storyStats && storyStats.length > 0 && ( @@ -100,7 +141,12 @@ export function WriterTradingStats({ > Story #{s.storylineId} - {formatUnits(s.totalSupply, 18)} tokens minted + {formatUnits(s.totalSupply, 18)} minted + {s.holderCount > 0 && ( + + · {s.holderCount} holders + + )}
))} From 57416f8060463d6ca937bdecd25d13e5d042f998 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:26:02 +0000 Subject: [PATCH 3/5] [#29] Add royalties to total earned metric Total Earned now sums royalties (from MCV2_Bond.getRoyaltyInfo) + donations, with a breakdown showing both components. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WriterTradingStats.tsx | 37 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index 9b374b3d..1846d5c8 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -3,8 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import { publicClient } from "../../lib/rpc"; -import { erc20Abi } from "../../lib/price"; -import { IS_TESTNET } from "../../lib/contracts/constants"; +import { erc20Abi, mcv2BondAbi } from "../../lib/price"; +import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; import { supabase, type Donation } from "../../lib/supabase"; interface WriterTradingStatsProps { @@ -93,8 +93,34 @@ export function WriterTradingStats({ enabled: storylineTokens.length > 0, }); + // Fetch unclaimed royalties across all storylines + const { data: totalRoyalties } = useQuery({ + queryKey: ["writer-total-royalties", storylineTokens.map((t) => t.tokenAddress)], + queryFn: async () => { + let total = BigInt(0); + for (const t of storylineTokens) { + try { + const result = await publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [t.tokenAddress], + }); + total += result[0]; + } catch { + // Skip on error + } + } + return total; + }, + enabled: storylineTokens.length > 0, + }); + if (storylineTokens.length === 0) return null; + const totalEarned = + (totalDonations ?? BigInt(0)) + (totalRoyalties ?? BigInt(0)); + const totalVolume = (storyStats ?? []).reduce( (sum, s) => sum + s.totalSupply, BigInt(0), @@ -106,10 +132,13 @@ export function WriterTradingStats({
- Total Donations + Total Earned - {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel} + {formatUnits(totalEarned, 18)} {reserveLabel} + + + {formatUnits(totalRoyalties ?? BigInt(0), 18)} royalties + {formatUnits(totalDonations ?? BigInt(0), 18)} donations
From e813c3e28d4f24de514dbf1e82ed213e33bba836 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:48:55 +0000 Subject: [PATCH 4/5] [#29] Split into Donations Received and Unclaimed Royalties Replaces misleading "Total Earned" (which understated after claims) with two separate metrics: Donations Received (lifetime from DB) and Unclaimed Royalties (current on-chain balance from getRoyaltyInfo). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WriterTradingStats.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index 1846d5c8..b90fa311 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -118,9 +118,6 @@ export function WriterTradingStats({ if (storylineTokens.length === 0) return null; - const totalEarned = - (totalDonations ?? BigInt(0)) + (totalRoyalties ?? BigInt(0)); - const totalVolume = (storyStats ?? []).reduce( (sum, s) => sum + s.totalSupply, BigInt(0), @@ -129,16 +126,21 @@ export function WriterTradingStats({ return (

Trading Stats

-
+
- Total Earned + Donations Received - {formatUnits(totalEarned, 18)} {reserveLabel} + {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel} + +
+
+ + Unclaimed Royalties - - {formatUnits(totalRoyalties ?? BigInt(0), 18)} royalties + {formatUnits(totalDonations ?? BigInt(0), 18)} donations + + {formatUnits(totalRoyalties ?? BigInt(0), 18)} {reserveLabel}
From 1327464a88bea36664afca8b2786d3cdc923b174 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:50:06 +0000 Subject: [PATCH 5/5] [#29] Explicitly defer lifetime total earned metric Adds notice that lifetime total earned requires a royalty-claim indexer. Shows available metrics (donations, unclaimed royalties) without a misleading combined total. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WriterTradingStats.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index b90fa311..7a834902 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -126,6 +126,9 @@ export function WriterTradingStats({ return (

Trading Stats

+

+ Lifetime total earned requires a royalty-claim indexer — showing available metrics. +