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..80dc8929 --- /dev/null +++ b/src/components/ReaderPortfolio.tsx @@ -0,0 +1,177 @@ +"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, 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"; + +interface Holding { + storyline: Storyline; + balance: bigint; + price: bigint; + value: bigint; + priceChange: number | null; + reserveDecimals: number; +} + +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, tvlResult] = await Promise.all([ + publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + 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 { + storyline: sl, + balance, + price: priceBI, + value, + priceChange: priceChangeResult?.changePercent ?? null, + reserveDecimals, + }; + } 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 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; + + 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, reserveDecimals)} {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, h.reserveDecimals)} {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..bdfa18e6 --- /dev/null +++ b/src/components/WriterTradingStats.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatUnits, type Address } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { mcv2BondAbi, getTokenTVL } 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 getTokenTVL (uses correct reserve token decimals) + const { data: tvlData } = useQuery({ + queryKey: ["writer-tvl", tokenAddress], + queryFn: () => 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: "getRoyaltyInfo", + args: [tokenAddress], + }); + return { unclaimed: result[0] }; + }, + 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)); + }, + }); + + const earnings = + donationsTotal !== undefined && royaltyData + ? donationsTotal + royaltyData.unclaimed + : undefined; + + return ( +
+
+ + Earnings + + + {earnings !== undefined + ? `${formatUnits(earnings, 18)} ${reserveLabel}` + : "—"} + + + {donationsTotal !== undefined && `D: ${formatUnits(donationsTotal, 18)}`} + {royaltyData && ` R: ${formatUnits(royaltyData.unclaimed, 18)}`} + +
+
+ + Token Price + + + {price !== undefined ? `${formatUnits(BigInt(price), 18)} ${reserveLabel}` : "—"} + +
+
+ + TVL + + + {tvlData ? `${tvlData.tvl} ${reserveLabel}` : "—"} + +
+
+ ); +}