From 909ae3a950efa2438e267ec093946a518f1e0aee Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 16:21:20 +0000 Subject: [PATCH 1/4] [#502] Add writer stats, per-story metrics, and agent extras to profile - Writer stats section: total storylines, total plots, total donations received, and claimable royalties (own profile only via wallet) - Per-story metrics: title, plot count, token price, holder count, view count, genre, active/complete status, creation date - AI agent extras: average plots per story, genre distribution tags, model info from ERC-8004 agentURI - Enhanced empty state for addresses with no stories - Holder count derived from trade_history (net positive mint/burn) Fixes #502 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 287 ++++++++++++++++++++++++----- 1 file changed, 244 insertions(+), 43 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 5ecaa650..007379de 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -2,15 +2,17 @@ import { useState } from "react"; import { useParams } from "next/navigation"; +import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; -import { formatUnits } from "viem"; +import { formatUnits, type Address } from "viem"; import Link from "next/link"; import { supabase, type Storyline, type Donation, type TradeHistory } from "../../../../lib/supabase"; -import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL } from "../../../../lib/contracts/constants"; +import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } from "../../../../lib/contracts/constants"; import { getFarcasterProfile, fetchAgentMetadata } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; -import { formatPrice } from "../../../../lib/format"; -import { AgentBadge } from "../../../components/AgentBadge"; +import { formatPrice, formatSupply } from "../../../../lib/format"; +import { getTokenPrice, mcv2BondAbi, type TokenPriceInfo } from "../../../../lib/price"; +import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; @@ -19,6 +21,8 @@ type Tab = "stories" | "portfolio" | "activity"; export default function ProfilePage() { const params = useParams<{ address: string }>(); const address = params.address.toLowerCase(); + const { address: connectedAddress } = useAccount(); + const isOwnProfile = connectedAddress?.toLowerCase() === address; const [tab, setTab] = useState("stories"); @@ -63,13 +67,24 @@ export default function ProfilePage() { {/* Tab content */} - {tab === "stories" && } + {tab === "stories" && ( + + )} {tab === "portfolio" && } {tab === "activity" && } ); } +// --------------------------------------------------------------------------- +// Profile Header (unchanged from #501) +// --------------------------------------------------------------------------- + function ProfileHeader({ address, fcProfile, @@ -186,10 +201,20 @@ function ProfileHeader({ } // --------------------------------------------------------------------------- -// Stories Tab +// Stories Tab — writer stats + story portfolio // --------------------------------------------------------------------------- -function StoriesTab({ address }: { address: string }) { +function StoriesTab({ + address, + isAgent, + agentMeta, + isOwnProfile, +}: { + address: string; + isAgent: boolean; + agentMeta: AgentMetadata | null; + isOwnProfile: boolean; +}) { const { data: storylines = [], isLoading, error } = useQuery({ queryKey: ["profile-storylines", address], queryFn: async () => { @@ -207,53 +232,229 @@ function StoriesTab({ address }: { address: string }) { }, }); + // Fetch donations received as writer (across all storylines) + const storylineIds = storylines.map((s) => s.storyline_id); + const { data: donationsReceived = [] } = useQuery({ + queryKey: ["profile-donations-received", address, storylineIds], + queryFn: async () => { + if (!supabase || storylineIds.length === 0) return []; + const { data } = await supabase + .from("donations") + .select("amount") + .in("storyline_id", storylineIds) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + return (data ?? []) as { amount: string }[]; + }, + enabled: storylineIds.length > 0, + }); + + // Claimable royalties (own profile only) + const { data: royaltyInfo } = useQuery({ + queryKey: ["profile-royalties", address], + queryFn: async () => { + const [balance, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [address as Address, PLOT_TOKEN], + }); + return { unclaimed: balance, claimed }; + }, + enabled: isOwnProfile, + }); + if (isLoading) return

Loading...

; if (error) return

Failed to load storylines.

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

No storylines yet.

; + return ( +
+

No storylines yet.

+

+ This address hasn't created any stories on PlotLink. +

+
+ ); + } + + // Compute writer stats + const totalPlots = storylines.reduce((sum, s) => sum + s.plot_count, 0); + const totalDonations = donationsReceived.reduce( + (sum, d) => sum + BigInt(d.amount), + BigInt(0), + ); + + // Agent extras + const avgPlotsPerStory = storylines.length > 0 + ? (totalPlots / storylines.length).toFixed(1) + : "0"; + const genreCounts = new Map(); + for (const s of storylines) { + if (s.genre) genreCounts.set(s.genre, (genreCounts.get(s.genre) ?? 0) + 1); } + const sortedGenres = Array.from(genreCounts.entries()).sort((a, b) => b[1] - a[1]); return ( -
- {storylines.map((s) => ( -
-
- - {s.title} - -
- {s.genre && ( - - {s.genre} - - )} - {s.sunset && ( - - complete - - )} -
-
-
- - {s.plot_count} {s.plot_count === 1 ? "plot" : "plots"} +
+ {/* Writer Stats */} +
+

Writer Stats

+
+ + + BigInt(0) + ? `${formatPrice(formatUnits(totalDonations, 18))} ${RESERVE_LABEL}` + : "—"} + /> + {isOwnProfile && royaltyInfo ? ( + BigInt(0) + ? `${formatPrice(formatUnits(royaltyInfo.unclaimed, 18))} ${RESERVE_LABEL}` + : "—"} + /> + ) : ( + + )} +
+
+ + {/* Agent extras */} + {isAgent && ( +
+

Agent Insights

+
+ + Avg plots/story: {avgPlotsPerStory} - {formatViewCount(s.view_count)} views - {s.block_timestamp && ( - - {new Date(s.block_timestamp).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} + {agentMeta?.llmModel && ( + + Model: {agentMeta.llmModel} )}
+ {sortedGenres.length > 0 && ( +
+ {sortedGenres.map(([genre, count]) => ( + + {genre} ({count}) + + ))} +
+ )}
- ))} + )} + + {/* Story portfolio */} +
+ {storylines.map((s) => ( + + ))} +
+
+ ); +} + +function StatCell({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function StoryRow({ storyline }: { storyline: Storyline }) { + const tokenAddr = storyline.token_address as Address; + + const { data: priceInfo } = useQuery({ + queryKey: ["profile-story-price", storyline.token_address], + queryFn: () => getTokenPrice(tokenAddr, browserClient), + enabled: !!storyline.token_address, + staleTime: 60000, + }); + + // Count unique holders from trade_history + const { data: holderCount } = useQuery({ + queryKey: ["profile-story-holders", storyline.storyline_id], + queryFn: async () => { + if (!supabase) return 0; + const { data } = await supabase + .from("trade_history") + .select("user_address, event_type") + .eq("storyline_id", storyline.storyline_id) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!data) return 0; + // Count net positive holders + const balances = new Map(); + for (const t of data) { + if (!t.user_address) continue; + const cur = balances.get(t.user_address) ?? 0; + balances.set(t.user_address, t.event_type === "mint" ? cur + 1 : cur - 1); + } + return Array.from(balances.values()).filter((b) => b > 0).length; + }, + staleTime: 60000, + }); + + return ( +
+
+ + {storyline.title} + +
+ {storyline.genre && ( + + {storyline.genre} + + )} + {storyline.sunset ? ( + + complete + + ) : ( + + active + + )} +
+
+ +
+ + {storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} + + + {priceInfo + ? `${formatPrice(priceInfo.pricePerToken)} ${RESERVE_LABEL}` + : "—"} + + + {holderCount !== undefined ? `${holderCount} holder${holderCount !== 1 ? "s" : ""}` : "—"} + + + {formatViewCount(storyline.view_count)} views + +
+ + {storyline.block_timestamp && ( +
+ Created{" "} + {new Date(storyline.block_timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+ )}
); } From 122e4c83417126b78fd42b30880a3dff3dd75001 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 16:23:39 +0000 Subject: [PATCH 2/4] [#502] Fix unused import, compute total holders across all storylines - Remove unused formatSupply import - Add aggregate holder count query across all writer's storylines (from trade_history net positions) - Always show Holders stat; add Claimable as 5th stat for own profile Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 34 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 007379de..61909eda 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -10,7 +10,7 @@ import { supabase, type Storyline, type Donation, type TradeHistory } from "../. import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } from "../../../../lib/contracts/constants"; import { getFarcasterProfile, fetchAgentMetadata } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; -import { formatPrice, formatSupply } from "../../../../lib/format"; +import { formatPrice } from "../../../../lib/format"; import { getTokenPrice, mcv2BondAbi, type TokenPriceInfo } from "../../../../lib/price"; import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; @@ -248,6 +248,28 @@ function StoriesTab({ enabled: storylineIds.length > 0, }); + // Total token holders across all writer's storylines + const { data: totalHolders } = useQuery({ + queryKey: ["profile-total-holders", address, storylineIds], + queryFn: async () => { + if (!supabase || storylineIds.length === 0) return 0; + const { data } = await supabase + .from("trade_history") + .select("user_address, event_type") + .in("storyline_id", storylineIds) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!data) return 0; + const balances = new Map(); + for (const t of data as { user_address: string | null; event_type: string }[]) { + if (!t.user_address) continue; + const cur = balances.get(t.user_address) ?? 0; + balances.set(t.user_address, t.event_type === "mint" ? cur + 1 : cur - 1); + } + return Array.from(balances.values()).filter((b) => b > 0).length; + }, + enabled: storylineIds.length > 0, + }); + // Claimable royalties (own profile only) const { data: royaltyInfo } = useQuery({ queryKey: ["profile-royalties", address], @@ -298,24 +320,26 @@ function StoriesTab({ {/* Writer Stats */}

Writer Stats

-
+
+ BigInt(0) ? `${formatPrice(formatUnits(totalDonations, 18))} ${RESERVE_LABEL}` : "—"} /> - {isOwnProfile && royaltyInfo ? ( + {isOwnProfile && royaltyInfo && ( BigInt(0) ? `${formatPrice(formatUnits(royaltyInfo.unclaimed, 18))} ${RESERVE_LABEL}` : "—"} /> - ) : ( - )}
From bfae95c9d65d384b33ba04a99a381f5a7713398e Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:11:26 +0000 Subject: [PATCH 3/4] [#502] Replace inaccurate holder count with token supply from trade_history Event-based holder counting (+1 mint / -1 burn) is unreliable because trade_history doesn't persist per-event token amounts. Replace with total_supply from the latest trade_history entry per storyline, which is accurate and already indexed. - Writer stats: "Token Supply" replaces "Holders" - Per-story row: shows supply from latest trade entry - Both aggregate and per-story queries now read total_supply field Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 70 ++++++++++++++++-------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 61909eda..ec7c04b8 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -248,24 +248,24 @@ function StoriesTab({ enabled: storylineIds.length > 0, }); - // Total token holders across all writer's storylines - const { data: totalHolders } = useQuery({ - queryKey: ["profile-total-holders", address, storylineIds], + // Total token supply across all writer's storylines (from latest trade per storyline) + const { data: totalSupplyAcross } = useQuery({ + queryKey: ["profile-total-supply", address, storylineIds], queryFn: async () => { if (!supabase || storylineIds.length === 0) return 0; - const { data } = await supabase - .from("trade_history") - .select("user_address, event_type") - .in("storyline_id", storylineIds) - .eq("contract_address", STORY_FACTORY.toLowerCase()); - if (!data) return 0; - const balances = new Map(); - for (const t of data as { user_address: string | null; event_type: string }[]) { - if (!t.user_address) continue; - const cur = balances.get(t.user_address) ?? 0; - balances.set(t.user_address, t.event_type === "mint" ? cur + 1 : cur - 1); + // Get the latest trade per storyline to read current total_supply + let total = 0; + for (const sid of storylineIds) { + const { data } = await supabase + .from("trade_history") + .select("total_supply") + .eq("storyline_id", sid) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_number", { ascending: false }) + .limit(1); + if (data && data.length > 0) total += data[0].total_supply; } - return Array.from(balances.values()).filter((b) => b > 0).length; + return total; }, enabled: storylineIds.length > 0, }); @@ -324,8 +324,10 @@ function StoriesTab({ 0 + ? formatSupplyCompact(totalSupplyAcross) + : "—"} /> { if (!supabase) return 0; const { data } = await supabase .from("trade_history") - .select("user_address, event_type") + .select("total_supply") .eq("storyline_id", storyline.storyline_id) - .eq("contract_address", STORY_FACTORY.toLowerCase()); - if (!data) return 0; - // Count net positive holders - const balances = new Map(); - for (const t of data) { - if (!t.user_address) continue; - const cur = balances.get(t.user_address) ?? 0; - balances.set(t.user_address, t.event_type === "mint" ? cur + 1 : cur - 1); - } - return Array.from(balances.values()).filter((b) => b > 0).length; + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_number", { ascending: false }) + .limit(1); + return data && data.length > 0 ? data[0].total_supply : 0; }, staleTime: 60000, }); @@ -462,7 +458,9 @@ function StoryRow({ storyline }: { storyline: Storyline }) { : "—"} - {holderCount !== undefined ? `${holderCount} holder${holderCount !== 1 ? "s" : ""}` : "—"} + {storySupply !== undefined && storySupply > 0 + ? `${formatSupplyCompact(storySupply)} supply` + : "—"} {formatViewCount(storyline.view_count)} views @@ -675,6 +673,14 @@ function ActivityTab({ address }: { address: string }) { // Helpers // --------------------------------------------------------------------------- +function formatSupplyCompact(n: number): string { + if (n === 0) return "0"; + if (n < 1) return n.toFixed(4); + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toFixed(0); +} + function formatViewCount(n: number): string { if (n < 1000) return String(n); if (n < 10000) return `${(n / 1000).toFixed(1)}k`; From eb236ee721a09a22a9a3bc3ab119dcd0bdd49f6b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:14:05 +0000 Subject: [PATCH 4/4] [#502] Use on-chain balanceOf multicall for accurate holder counts Replace trade_history total_supply (wrong metric) with actual holder counts derived from on-chain balanceOf multicalls: - Get unique trader addresses from trade_history - Multicall balanceOf for each (user, token) pair via browserClient - Count addresses with balance > 0 Applied to both aggregate writer stat and per-story holder count. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 131 ++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 41 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index ec7c04b8..88f5f7ad 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -11,7 +11,7 @@ import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } fro import { getFarcasterProfile, fetchAgentMetadata } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; import { formatPrice } from "../../../../lib/format"; -import { getTokenPrice, mcv2BondAbi, type TokenPriceInfo } from "../../../../lib/price"; +import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo } from "../../../../lib/price"; import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; @@ -248,26 +248,67 @@ function StoriesTab({ enabled: storylineIds.length > 0, }); - // Total token supply across all writer's storylines (from latest trade per storyline) - const { data: totalSupplyAcross } = useQuery({ - queryKey: ["profile-total-supply", address, storylineIds], + // Total token holders across all writer's storylines (on-chain balanceOf) + const { data: totalHolders } = useQuery({ + queryKey: ["profile-total-holders", address, storylineIds], queryFn: async () => { if (!supabase || storylineIds.length === 0) return 0; - // Get the latest trade per storyline to read current total_supply - let total = 0; - for (const sid of storylineIds) { - const { data } = await supabase - .from("trade_history") - .select("total_supply") - .eq("storyline_id", sid) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_number", { ascending: false }) - .limit(1); - if (data && data.length > 0) total += data[0].total_supply; + // Get unique trader addresses across all storylines + const { data: trades } = await supabase + .from("trade_history") + .select("user_address, storyline_id") + .in("storyline_id", storylineIds) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!trades || trades.length === 0) return 0; + + // Build map: token_address -> unique user addresses + const tokenByStoryline = new Map(); + for (const s of storylines) { + if (s.token_address) tokenByStoryline.set(s.storyline_id, s.token_address); + } + + // Deduplicate: (user, token) pairs + const pairs = new Set(); + const pairList: { user: string; token: string }[] = []; + for (const t of trades as { user_address: string | null; storyline_id: number }[]) { + if (!t.user_address) continue; + const token = tokenByStoryline.get(t.storyline_id); + if (!token) continue; + const key = `${t.user_address}:${token}`; + if (!pairs.has(key)) { + pairs.add(key); + pairList.push({ user: t.user_address, token }); + } } - return total; + if (pairList.length === 0) return 0; + + // Multicall balanceOf for each (user, token) pair + const results = await browserClient.multicall({ + contracts: pairList.map((p) => ({ + address: p.token as Address, + abi: erc20Abi, + functionName: "balanceOf" as const, + args: [p.user as Address], + })), + allowFailure: true, + }); + + let holders = 0; + const counted = new Set(); + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r.status === "success" && (r.result as bigint) > BigInt(0)) { + const userKey = pairList[i].user.toLowerCase(); + if (!counted.has(userKey)) { + counted.add(userKey); + holders++; + } + } + } + return holders; }, enabled: storylineIds.length > 0, + staleTime: 60000, }); // Claimable royalties (own profile only) @@ -324,10 +365,8 @@ function StoriesTab({ 0 - ? formatSupplyCompact(totalSupplyAcross) - : "—"} + label="Holders" + value={totalHolders !== undefined ? String(totalHolders) : "—"} /> { - if (!supabase) return 0; - const { data } = await supabase + if (!supabase || !storyline.token_address) return 0; + const { data: trades } = await supabase .from("trade_history") - .select("total_supply") + .select("user_address") .eq("storyline_id", storyline.storyline_id) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_number", { ascending: false }) - .limit(1); - return data && data.length > 0 ? data[0].total_supply : 0; + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!trades || trades.length === 0) return 0; + + const uniqueUsers = [...new Set( + (trades as { user_address: string | null }[]) + .map((t) => t.user_address) + .filter(Boolean) as string[] + )]; + if (uniqueUsers.length === 0) return 0; + + const results = await browserClient.multicall({ + contracts: uniqueUsers.map((u) => ({ + address: tokenAddr, + abi: erc20Abi, + functionName: "balanceOf" as const, + args: [u as Address], + })), + allowFailure: true, + }); + + return results.filter( + (r) => r.status === "success" && (r.result as bigint) > BigInt(0), + ).length; }, staleTime: 60000, + enabled: !!storyline.token_address, }); return ( @@ -458,9 +517,7 @@ function StoryRow({ storyline }: { storyline: Storyline }) { : "—"} - {storySupply !== undefined && storySupply > 0 - ? `${formatSupplyCompact(storySupply)} supply` - : "—"} + {holderCount !== undefined ? `${holderCount} holder${holderCount !== 1 ? "s" : ""}` : "—"} {formatViewCount(storyline.view_count)} views @@ -673,14 +730,6 @@ function ActivityTab({ address }: { address: string }) { // Helpers // --------------------------------------------------------------------------- -function formatSupplyCompact(n: number): string { - if (n === 0) return "0"; - if (n < 1) return n.toFixed(4); - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return n.toFixed(0); -} - function formatViewCount(n: number): string { if (n < 1000) return String(n); if (n < 10000) return `${(n / 1000).toFixed(1)}k`;