From 796be3870f9aff9f11cb96e7f546abe567287587 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:31:41 +0000 Subject: [PATCH 1/3] [#29] Add dashboard trading stats for writer and reader Writer dashboard: - Per-story token price (priceForNextMint), TVL (tokenBond), donations total - Displayed in grid below existing story details Reader dashboard: - Portfolio section: token holdings via balanceOf for each storyline - Portfolio value: holdings * priceForNextMint - 24h price change per holding via get24hPriceChange - Best-performing pick highlight - Replaces Phase 5 placeholder Fixes #29 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/dashboard/reader/page.tsx | 9 +- src/app/dashboard/writer/page.tsx | 12 +- src/components/ReaderPortfolio.tsx | 172 ++++++++++++++++++++++++++ src/components/WriterTradingStats.tsx | 96 ++++++++++++++ 4 files changed, 278 insertions(+), 11 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..915cf23f 100644 --- a/src/app/dashboard/reader/page.tsx +++ b/src/app/dashboard/reader/page.tsx @@ -5,6 +5,7 @@ import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { supabase, type Donation } from "../../../../lib/supabase"; import { ConnectWallet } from "../../../components/ConnectWallet"; +import { ReaderPortfolio } from "../../../components/ReaderPortfolio"; import { formatUnits } from "viem"; const PAGE_SIZE = 50; @@ -76,13 +77,7 @@ export default function ReaderDashboard() { Reader Dashboard - {/* --- Portfolio section (Phase 5) --- */} -
-

Portfolio

-

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

-
+ {/* --- Donation History --- */}
diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index 9c334d10..2e618867 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"; @@ -130,10 +131,13 @@ function StorylineDetail({ storyline }: { storyline: Storyline }) { )} {storyline.token_address && ( - + <> + + + )} ); diff --git a/src/components/ReaderPortfolio.tsx b/src/components/ReaderPortfolio.tsx new file mode 100644 index 00000000..ee93bb82 --- /dev/null +++ b/src/components/ReaderPortfolio.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useAccount } from "wagmi"; +import { useQuery } from "@tanstack/react-query"; +import { formatUnits, type Address } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { erc20Abi, mcv2BondAbi, get24hPriceChange } from "../../lib/price"; +import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; +import { supabase, type Storyline } from "../../lib/supabase"; +import Link from "next/link"; + +interface Holding { + storyline: Storyline; + balance: bigint; + price: bigint; + value: bigint; + priceChange: number | null; +} + +export function ReaderPortfolio() { + const { address, isConnected } = useAccount(); + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + + const { data: holdings, isLoading } = useQuery({ + queryKey: ["reader-portfolio", address], + queryFn: async (): Promise => { + if (!address || !supabase) return []; + + // Get all non-hidden storylines with token addresses + const { data: storylines } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .neq("token_address", "") + .returns(); + + if (!storylines || storylines.length === 0) return []; + + // Check balance for each token (parallel) + const results = await Promise.all( + storylines.map(async (sl): Promise => { + const tokenAddr = sl.token_address as Address; + try { + const balance = await publicClient.readContract({ + address: tokenAddr, + abi: erc20Abi, + functionName: "balanceOf", + args: [address], + }); + + if (balance === BigInt(0)) return null; + + const [price, priceChangeResult] = await Promise.all([ + publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddr], + }), + get24hPriceChange(tokenAddr).catch(() => null), + ]); + + const priceBI = BigInt(price); + const value = (balance * priceBI) / BigInt(10 ** 18); + + return { + storyline: sl, + balance, + price: priceBI, + value, + priceChange: priceChangeResult?.changePercent ?? null, + }; + } catch { + return null; + } + }), + ); + + return results.filter((h): h is Holding => h !== null); + }, + enabled: isConnected && !!address, + }); + + const totalValue = holdings?.reduce((sum, h) => sum + h.value, BigInt(0)) ?? BigInt(0); + const bestPick = holdings && holdings.length > 0 + ? holdings.reduce((best, h) => + (h.priceChange ?? -Infinity) > (best.priceChange ?? -Infinity) ? h : best + ) + : null; + + if (!isConnected) return null; + + return ( +
+

Portfolio

+ + {isLoading && ( +

Loading holdings...

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

+ No token holdings found. Buy storyline tokens to build your portfolio. +

+ )} + + {holdings && holdings.length > 0 && ( + <> +
+
+ + Total Value + + + {formatUnits(totalValue, 18)} {reserveLabel} + +
+ {bestPick && bestPick.priceChange !== null && ( +
+ + Best Pick (24h) + + + {bestPick.storyline.title.slice(0, 20)} + {bestPick.storyline.title.length > 20 ? "..." : ""}{" "} + = 0 ? "text-accent" : "text-red-400"}> + {bestPick.priceChange >= 0 ? "+" : ""} + {bestPick.priceChange.toFixed(1)}% + + +
+ )} +
+ +
+ {holdings.map((h) => ( +
+
+ + {h.storyline.title} + +
+ {formatUnits(h.balance, 18)} tokens +
+
+
+
+ {formatUnits(h.value, 18)} {reserveLabel} +
+ {h.priceChange !== null && ( +
= 0 ? "text-accent" : "text-red-400"}`} + > + {h.priceChange >= 0 ? "+" : ""} + {h.priceChange.toFixed(1)}% +
+ )} +
+
+ ))} +
+ + )} +
+ ); +} diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx new file mode 100644 index 00000000..51aae3af --- /dev/null +++ b/src/components/WriterTradingStats.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatUnits, type Address } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { mcv2BondAbi } from "../../lib/price"; +import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; +import type { Storyline } from "../../lib/supabase"; +import { supabase } from "../../lib/supabase"; + +interface WriterTradingStatsProps { + storyline: Storyline; +} + +export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { + const tokenAddress = storyline.token_address as Address; + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + + // Fetch token price + const { data: price } = useQuery({ + queryKey: ["writer-price", tokenAddress], + queryFn: async () => { + const result = await publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + }); + return result; + }, + enabled: !!tokenAddress, + }); + + // Fetch TVL via tokenBond + const { data: bondData } = useQuery({ + queryKey: ["writer-bond", tokenAddress], + queryFn: async () => { + const result = await publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "tokenBond", + args: [tokenAddress], + }); + const [, , , , , reserveBalance] = result; + return { reserveBalance }; + }, + enabled: !!tokenAddress, + }); + + // Fetch total donations for this storyline + const { data: donationsTotal } = useQuery({ + queryKey: ["writer-donations", storyline.storyline_id], + queryFn: async () => { + if (!supabase) return BigInt(0); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data } = await (supabase.from("donations") as any) + .select("amount") + .eq("storyline_id", storyline.storyline_id); + if (!data) return BigInt(0); + return (data as { amount: string }[]).reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + }, + }); + + return ( +
+
+ + Token Price + + + {price !== undefined ? `${formatUnits(BigInt(price), 18)} ${reserveLabel}` : "—"} + +
+
+ + TVL + + + {bondData + ? `${formatUnits(bondData.reserveBalance, 18)} ${reserveLabel}` + : "—"} + +
+
+ + Donations + + + {donationsTotal !== undefined + ? `${formatUnits(donationsTotal, 18)} ${reserveLabel}` + : "—"} + +
+
+ ); +} From 3175d452cf24f09ca9e9a68f3d87c55ba7893b25 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:33:50 +0000 Subject: [PATCH 2/3] [#29] Add royalties to writer stats, fix decimals in both dashboards - Writer: added unclaimed royalties via getRoyaltyInfo, use getTokenTVL for correct reserve token decimals instead of hardcoded 18 - Reader: fetch reserve decimals via getTokenTVL, use per-holding decimals for value formatting - Changed writer grid to 2x2 (price, TVL, donations, royalties) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ReaderPortfolio.tsx | 13 +++++++--- src/components/WriterTradingStats.tsx | 36 +++++++++++++++++++-------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/components/ReaderPortfolio.tsx b/src/components/ReaderPortfolio.tsx index ee93bb82..80dc8929 100644 --- a/src/components/ReaderPortfolio.tsx +++ b/src/components/ReaderPortfolio.tsx @@ -4,7 +4,7 @@ import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import { publicClient } from "../../lib/rpc"; -import { erc20Abi, mcv2BondAbi, get24hPriceChange } from "../../lib/price"; +import { erc20Abi, mcv2BondAbi, get24hPriceChange, getTokenTVL } from "../../lib/price"; import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; import { supabase, type Storyline } from "../../lib/supabase"; import Link from "next/link"; @@ -15,6 +15,7 @@ interface Holding { price: bigint; value: bigint; priceChange: number | null; + reserveDecimals: number; } export function ReaderPortfolio() { @@ -50,7 +51,7 @@ export function ReaderPortfolio() { if (balance === BigInt(0)) return null; - const [price, priceChangeResult] = await Promise.all([ + const [price, priceChangeResult, tvlResult] = await Promise.all([ publicClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, @@ -58,9 +59,11 @@ export function ReaderPortfolio() { args: [tokenAddr], }), get24hPriceChange(tokenAddr).catch(() => null), + getTokenTVL(tokenAddr).catch(() => null), ]); const priceBI = BigInt(price); + const reserveDecimals = tvlResult?.decimals ?? 18; const value = (balance * priceBI) / BigInt(10 ** 18); return { @@ -69,6 +72,7 @@ export function ReaderPortfolio() { price: priceBI, value, priceChange: priceChangeResult?.changePercent ?? null, + reserveDecimals, }; } catch { return null; @@ -82,6 +86,7 @@ export function ReaderPortfolio() { }); 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 @@ -112,7 +117,7 @@ export function ReaderPortfolio() { Total Value - {formatUnits(totalValue, 18)} {reserveLabel} + {formatUnits(totalValue, reserveDecimals)} {reserveLabel} {bestPick && bestPick.priceChange !== null && ( @@ -151,7 +156,7 @@ export function ReaderPortfolio() {
- {formatUnits(h.value, 18)} {reserveLabel} + {formatUnits(h.value, h.reserveDecimals)} {reserveLabel}
{h.priceChange !== null && (
getTokenTVL(tokenAddress), + enabled: !!tokenAddress, + }); + + // Fetch unclaimed royalties + const { data: royaltyData } = useQuery({ + queryKey: ["writer-royalty", tokenAddress], queryFn: async () => { const result = await publicClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, - functionName: "tokenBond", + functionName: "getRoyaltyInfo", args: [tokenAddress], }); - const [, , , , , reserveBalance] = result; - return { reserveBalance }; + return { unclaimed: result[0] }; }, enabled: !!tokenAddress, }); @@ -62,7 +68,7 @@ export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { }); return ( -
+
Token Price @@ -76,9 +82,7 @@ export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { TVL - {bondData - ? `${formatUnits(bondData.reserveBalance, 18)} ${reserveLabel}` - : "—"} + {tvlData ? `${tvlData.tvl} ${reserveLabel}` : "—"}
@@ -91,6 +95,16 @@ export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { : "—"}
+
+ + Royalties + + + {royaltyData + ? `${formatUnits(royaltyData.unclaimed, 18)} ${reserveLabel}` + : "—"} + +
); } From c77073b5dc8df75f7316b941636747214a3189d6 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:36:05 +0000 Subject: [PATCH 3/3] [#29] Add combined Earnings stat (donations + royalties) Earnings shown as primary metric with D/R breakdown below. Grid changed to 3-col: Earnings, Token Price, TVL. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WriterTradingStats.tsx | 37 +++++++++++++-------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index eea73d94..bdfa18e6 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -67,42 +67,41 @@ export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { }, }); + const earnings = + donationsTotal !== undefined && royaltyData + ? donationsTotal + royaltyData.unclaimed + : undefined; + return ( -
+
- Token Price - - - {price !== undefined ? `${formatUnits(BigInt(price), 18)} ${reserveLabel}` : "—"} + Earnings -
-
- - TVL + + {earnings !== undefined + ? `${formatUnits(earnings, 18)} ${reserveLabel}` + : "—"} - - {tvlData ? `${tvlData.tvl} ${reserveLabel}` : "—"} + + {donationsTotal !== undefined && `D: ${formatUnits(donationsTotal, 18)}`} + {royaltyData && ` R: ${formatUnits(royaltyData.unclaimed, 18)}`}
- Donations + Token Price - {donationsTotal !== undefined - ? `${formatUnits(donationsTotal, 18)} ${reserveLabel}` - : "—"} + {price !== undefined ? `${formatUnits(BigInt(price), 18)} ${reserveLabel}` : "—"}
- Royalties + TVL - {royaltyData - ? `${formatUnits(royaltyData.unclaimed, 18)} ${reserveLabel}` - : "—"} + {tvlData ? `${tvlData.tvl} ${reserveLabel}` : "—"}