diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 88f5f7ad..64c009ee 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -542,64 +542,314 @@ function StoryRow({ storyline }: { storyline: Storyline }) { // Portfolio Tab // --------------------------------------------------------------------------- +interface PortfolioHolding { + storyline: Storyline; + balance: bigint; + price: bigint; + value: bigint; + entryPrice: number | null; + lastTraded: string | null; +} + function PortfolioTab({ address }: { address: string }) { - const { data: trades = [], isLoading, error } = useQuery({ - queryKey: ["profile-portfolio", address], + // Fetch on-chain token holdings + const { data: holdings, isLoading: holdingsLoading } = useQuery({ + queryKey: ["profile-holdings", address], + queryFn: async (): Promise => { + if (!supabase) return []; + + // Scan all storylines with tokens (matches ReaderPortfolio pattern) + // to catch holdings acquired via direct transfers, not just indexed trades + const { data: storylines } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .neq("token_address", "") + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .returns(); + if (!storylines || storylines.length === 0) return []; + + // Multicall balanceOf for all storyline tokens + const balanceResults = await browserClient.multicall({ + contracts: storylines.map((sl) => ({ + address: sl.token_address as Address, + abi: erc20Abi, + functionName: "balanceOf" as const, + args: [address as Address], + })), + allowFailure: true, + }); + + const held = storylines + .map((sl, i) => ({ sl, balance: balanceResults[i] })) + .filter((h) => h.balance.status === "success" && (h.balance.result as bigint) > BigInt(0)); + if (held.length === 0) return []; + + // Fetch prices for held tokens + const results = await Promise.all( + held.map(async ({ sl, balance: balResult }): Promise => { + 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 priceBI = BigInt(price); + const value = (balance * priceBI) / BigInt(10 ** 18); + + // Derive entry price from first mint in trade_history + let entryPrice: number | null = null; + let lastTraded: string | null = null; + if (supabase) { + const { data: firstMint } = await supabase + .from("trade_history") + .select("price_per_token, block_timestamp") + .eq("user_address", address) + .eq("storyline_id", sl.storyline_id) + .eq("event_type", "mint") + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: true }) + .limit(1); + if (firstMint && firstMint.length > 0) { + entryPrice = firstMint[0].price_per_token; + } + const { data: lastTrade } = await supabase + .from("trade_history") + .select("block_timestamp") + .eq("user_address", address) + .eq("storyline_id", sl.storyline_id) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .limit(1); + if (lastTrade && lastTrade.length > 0) { + lastTraded = lastTrade[0].block_timestamp; + } + } + + return { storyline: sl, balance, price: priceBI, value, entryPrice, lastTraded }; + } catch { + return null; + } + }), + ); + + // Sort by most recently traded, then largest value + return results + .filter((h): h is PortfolioHolding => h !== null) + .sort((a, b) => { + if (a.lastTraded && b.lastTraded) return b.lastTraded.localeCompare(a.lastTraded); + if (a.lastTraded) return -1; + if (b.lastTraded) return 1; + return Number(b.value - a.value); + }); + }, + staleTime: 60000, + }); + + // Donation history (given as reader) + const { data: donationsGiven = [], isLoading: donGivenLoading } = useQuery({ + queryKey: ["profile-donations-given", address], queryFn: async () => { if (!supabase) return []; - const { data, error } = await supabase - .from("trade_history") + const { data } = await supabase + .from("donations") .select("*") - .eq("user_address", address) + .eq("donor_address", address) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) - .limit(50) - .returns(); - if (error) throw error; + .limit(20) + .returns(); return data ?? []; }, }); + // Aggregate donations received as writer + const { data: donationsReceived, isLoading: donRecvLoading } = useQuery({ + queryKey: ["profile-donations-received-portfolio", address], + queryFn: async () => { + if (!supabase) return { total: BigInt(0), count: 0 }; + // Get storylines written by this address + const { data: writerStorylines } = await supabase + .from("storylines") + .select("storyline_id") + .eq("writer_address", address) + .eq("hidden", false) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!writerStorylines || writerStorylines.length === 0) { + return { total: BigInt(0), count: 0 }; + } + const sids = writerStorylines.map((s) => s.storyline_id); + const { data: donations } = await supabase + .from("donations") + .select("amount") + .in("storyline_id", sids) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!donations || donations.length === 0) return { total: BigInt(0), count: 0 }; + const total = donations.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + return { total, count: donations.length }; + }, + }); + + const isLoading = holdingsLoading || donGivenLoading || donRecvLoading; + if (isLoading) return

Loading...

; - if (error) return

Failed to load portfolio.

; - if (trades.length === 0) { - return

No trading activity yet.

; - } - // Group trades by storyline to show net position - const positions = new Map(); - for (const t of trades) { - const pos = positions.get(t.storyline_id) ?? { storylineId: t.storyline_id, mints: 0, burns: 0, lastTrade: t.block_timestamp }; - if (t.event_type === "mint") pos.mints++; - else if (t.event_type === "burn") pos.burns++; - positions.set(t.storyline_id, pos); + 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 totalDonated = donationsGiven.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + return ( -
-

Trading Activity

- {Array.from(positions.values()).map((pos) => ( -
-
- - Story #{pos.storylineId} - - - {new Date(pos.lastTrade).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} +
+ {/* Portfolio summary */} + {hasHoldings && ( + <> +
+

Portfolio Value

+ + {formatPrice(formatUnits(totalValue, 18))} {RESERVE_LABEL} + + + across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"}
-
- {pos.mints} mint{pos.mints !== 1 ? "s" : ""} - {pos.burns} burn{pos.burns !== 1 ? "s" : ""} + + {/* Token holdings */} +
+

Token Holdings

+ {holdings!.map((h) => ( +
+
+
+ + {h.storyline.title} + + {h.storyline.genre && ( + + {h.storyline.genre} + + )} +
+ + {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} + +
+
+ + Balance: {formatPrice(formatUnits(h.balance, 18))} tokens + + + Price: {formatPrice(formatUnits(h.price, 18))} {RESERVE_LABEL} + + {h.entryPrice !== null && h.entryPrice > 0 && ( + + Entry: {formatPrice(h.entryPrice)} {RESERVE_LABEL} + + )} + {h.lastTraded && ( + + Last traded:{" "} + + {new Date(h.lastTraded).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + + + )} +
+
+ ))} +
+ + )} + + {/* Donations received as writer */} + {hasDonationsReceived && ( +
+

Donations Received

+ + {formatPrice(formatUnits(donationsReceived!.total, 18))} {RESERVE_LABEL} + + + from {donationsReceived!.count} {donationsReceived!.count === 1 ? "donation" : "donations"} + +
+ )} + + {/* Donations given as reader */} + {hasDonationsGiven && ( +
+

+ Donations Given + {totalDonated > BigInt(0) && ( + + {formatPrice(formatUnits(totalDonated, 18))} {RESERVE_LABEL} total + + )} +

+
+ {donationsGiven.map((d) => ( +
+
+ + Story #{d.storyline_id} + + {d.block_timestamp && ( + + )} +
+
+ + {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL} + + {d.tx_hash && ( + + ↗ + + )} +
+
+ ))}
- ))} + )}
); }