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..7a834902 --- /dev/null +++ b/src/components/WriterTradingStats.tsx @@ -0,0 +1,191 @@ +"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 Donation } from "../../lib/supabase"; + +interface WriterTradingStatsProps { + writerAddress: Address; + storylineTokens: { storylineId: number; tokenAddress: Address }[]; +} + +interface StoryStats { + storylineId: number; + totalSupply: bigint; + holderCount: number; +} + +export function WriterTradingStats({ + writerAddress, + storylineTokens, +}: WriterTradingStatsProps) { + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + + 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, storyIds], + queryFn: async () => { + 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 { + const supply = await publicClient.readContract({ + address: t.tokenAddress, + abi: erc20Abi, + functionName: "totalSupply", + }); + results.push({ + storylineId: t.storylineId, + totalSupply: supply, + holderCount: donorCounts.get(t.storylineId) ?? 0, + }); + } catch { + results.push({ + storylineId: t.storylineId, + totalSupply: BigInt(0), + holderCount: donorCounts.get(t.storylineId) ?? 0, + }); + } + } + return results; + }, + 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 totalVolume = (storyStats ?? []).reduce( + (sum, s) => sum + s.totalSupply, + BigInt(0), + ); + + return ( +
+

Trading Stats

+

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

+
+
+ + Donations Received + + + {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel} + +
+
+ + Unclaimed Royalties + + + {formatUnits(totalRoyalties ?? BigInt(0), 18)} {reserveLabel} + +
+
+ + Total Tokens Minted + + + {formatUnits(totalVolume, 18)} + +
+
+ + Unique Holders + + + {(storyStats ?? []).reduce((sum, s) => sum + s.holderCount, 0)} + +
+
+ + {storyStats && storyStats.length > 0 && ( +
+ {storyStats + .filter((s) => s.totalSupply > BigInt(0)) + .map((s) => ( +
+ Story #{s.storylineId} + + {formatUnits(s.totalSupply, 18)} minted + {s.holderCount > 0 && ( + + · {s.holderCount} holders + + )} + +
+ ))} +
+ )} +
+ ); +}