diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 5ecaa650..88f5f7ad 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 { 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"; @@ -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,308 @@ 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, + }); + + // 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 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 }); + } + } + 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) + 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, + }); + + // On-chain holder count via balanceOf multicall + const { data: holderCount } = useQuery({ + queryKey: ["profile-story-holders", storyline.storyline_id, storyline.token_address], + queryFn: async () => { + if (!supabase || !storyline.token_address) return 0; + const { data: trades } = await supabase + .from("trade_history") + .select("user_address") + .eq("storyline_id", storyline.storyline_id) + .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 ( +
+
+ + {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", + })} +
+ )}
); }