diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 64c009ee..14676210 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -38,6 +38,20 @@ export default function ProfilePage() { const isAgent = !agentLoading && agentMeta !== null && agentMeta !== undefined; + // Cumulative claimed royalties (on-chain) + const { data: claimedRoyalties } = useQuery({ + queryKey: ["profile-claimed-royalties", address], + queryFn: async () => { + const [, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [address as Address, PLOT_TOKEN], + }); + return claimed; + }, + }); + return (
{/* Tab navigation */} @@ -92,6 +107,7 @@ function ProfileHeader({ agentMeta, agentLoading, isAgent, + claimedRoyalties, }: { address: string; fcProfile: FarcasterProfile | null; @@ -99,6 +115,7 @@ function ProfileHeader({ agentMeta: AgentMetadata | null; agentLoading: boolean; isAgent: boolean; + claimedRoyalties: bigint | null; }) { const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null; @@ -194,6 +211,13 @@ function ProfileHeader({ {!agentMeta?.description && fcProfile?.bio && (

{fcProfile.bio}

)} + + {/* Cumulative claimed royalties */} + {claimedRoyalties && claimedRoyalties > BigInt(0) && ( +
+ Royalties claimed: {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL} +
+ )}
@@ -258,7 +282,7 @@ function StoriesTab({ .from("trade_history") .select("user_address, storyline_id") .in("storyline_id", storylineIds) - .eq("contract_address", STORY_FACTORY.toLowerCase()); + .eq("contract_address", MCV2_BOND.toLowerCase()); if (!trades || trades.length === 0) return 0; // Build map: token_address -> unique user addresses @@ -452,7 +476,7 @@ function StoryRow({ storyline }: { storyline: Storyline }) { .from("trade_history") .select("user_address") .eq("storyline_id", storyline.storyline_id) - .eq("contract_address", STORY_FACTORY.toLowerCase()); + .eq("contract_address", MCV2_BOND.toLowerCase()); if (!trades || trades.length === 0) return 0; const uniqueUsers = [...new Set( @@ -609,7 +633,7 @@ function PortfolioTab({ address }: { address: string }) { .eq("user_address", address) .eq("storyline_id", sl.storyline_id) .eq("event_type", "mint") - .eq("contract_address", STORY_FACTORY.toLowerCase()) + .eq("contract_address", MCV2_BOND.toLowerCase()) .order("block_timestamp", { ascending: true }) .limit(1); if (firstMint && firstMint.length > 0) { @@ -620,7 +644,7 @@ function PortfolioTab({ address }: { address: string }) { .select("block_timestamp") .eq("user_address", address) .eq("storyline_id", sl.storyline_id) - .eq("contract_address", STORY_FACTORY.toLowerCase()) + .eq("contract_address", MCV2_BOND.toLowerCase()) .order("block_timestamp", { ascending: false }) .limit(1); if (lastTrade && lastTrade.length > 0) { @@ -855,123 +879,244 @@ function PortfolioTab({ address }: { address: string }) { } // --------------------------------------------------------------------------- -// Activity Tab +// Activity Tab — unified reverse-chronological feed // --------------------------------------------------------------------------- -const ACTIVITY_PAGE_SIZE = 20; +interface FeedEntry { + type: "created_storyline" | "published_plot" | "bought" | "sold" | "donated" | "rated" | "claimed_royalties"; + timestamp: string; + storylineId: number; + storyTitle?: string; + txHash?: string; + detail?: string; +} + +const FEED_PAGE_SIZE = 30; function ActivityTab({ address }: { address: string }) { - const { data: donations = [], isLoading: donLoading } = useQuery({ - queryKey: ["profile-donations", address], - queryFn: async () => { - if (!supabase) return []; - const { data } = await supabase - .from("donations") - .select("*") - .eq("donor_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .limit(ACTIVITY_PAGE_SIZE) - .returns(); - return data ?? []; - }, - }); + const [visibleCount, setVisibleCount] = useState(FEED_PAGE_SIZE); - const { data: ratings = [], isLoading: ratLoading } = useQuery({ - queryKey: ["profile-ratings", address], - queryFn: async () => { + const { data: feed = [], isLoading } = useQuery({ + queryKey: ["profile-activity-feed", address], + queryFn: async (): Promise => { if (!supabase) return []; - const { data } = await supabase - .from("ratings") - .select("*") - .eq("rater_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("created_at", { ascending: false }) - .limit(ACTIVITY_PAGE_SIZE); - return data ?? []; + + const PER_SOURCE_LIMIT = 200; + + // Fetch all event sources in parallel (bounded per source) + const [storylinesRes, plotsRes, tradesRes, donationsRes, ratingsRes] = await Promise.all([ + // Storylines created by this address + supabase + .from("storylines") + .select("storyline_id, title, block_timestamp, tx_hash") + .eq("writer_address", address) + .eq("hidden", false) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), + // Plots published by this address + supabase + .from("plots") + .select("storyline_id, plot_index, title, block_timestamp, tx_hash") + .eq("writer_address", address) + .eq("hidden", false) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), + // Trades by this address (trade_history uses MCV2_BOND as contract_address) + supabase + .from("trade_history") + .select("storyline_id, event_type, reserve_amount, price_per_token, block_timestamp, tx_hash") + .eq("user_address", address) + .eq("contract_address", MCV2_BOND.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), + // Donations by this address + supabase + .from("donations") + .select("storyline_id, amount, block_timestamp, tx_hash") + .eq("donor_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), + // Ratings by this address + supabase + .from("ratings") + .select("storyline_id, rating, created_at") + .eq("rater_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("created_at", { ascending: false }) + .limit(PER_SOURCE_LIMIT), + ]); + + const entries: FeedEntry[] = []; + + // Created storylines + for (const s of (storylinesRes.data ?? []) as { storyline_id: number; title: string; block_timestamp: string | null; tx_hash: string }[]) { + if (!s.block_timestamp) continue; + entries.push({ + type: "created_storyline", + timestamp: s.block_timestamp, + storylineId: s.storyline_id, + storyTitle: s.title, + txHash: s.tx_hash, + }); + } + + // Published plots (skip genesis plot_index=0, already covered by created_storyline) + for (const p of (plotsRes.data ?? []) as { storyline_id: number; plot_index: number; title: string; block_timestamp: string | null; tx_hash: string }[]) { + if (!p.block_timestamp || p.plot_index === 0) continue; + entries.push({ + type: "published_plot", + timestamp: p.block_timestamp, + storylineId: p.storyline_id, + detail: p.title || `Chapter ${p.plot_index}`, + txHash: p.tx_hash, + }); + } + + // Trades + for (const t of (tradesRes.data ?? []) as { storyline_id: number; event_type: string; reserve_amount: number; price_per_token: number; block_timestamp: string; tx_hash: string }[]) { + const tokenAmount = t.price_per_token > 0 + ? formatPrice(t.reserve_amount / t.price_per_token) + : null; + entries.push({ + type: t.event_type === "mint" ? "bought" : "sold", + timestamp: t.block_timestamp, + storylineId: t.storyline_id, + detail: tokenAmount + ? `${tokenAmount} tokens for ${formatPrice(t.reserve_amount)} ${RESERVE_LABEL}` + : `${formatPrice(t.reserve_amount)} ${RESERVE_LABEL}`, + txHash: t.tx_hash, + }); + } + + // Donations + for (const d of (donationsRes.data ?? []) as { storyline_id: number; amount: string; block_timestamp: string | null; tx_hash: string }[]) { + if (!d.block_timestamp) continue; + entries.push({ + type: "donated", + timestamp: d.block_timestamp, + storylineId: d.storyline_id, + detail: `${formatPrice(formatUnits(BigInt(d.amount), 18))} ${RESERVE_LABEL}`, + txHash: d.tx_hash, + }); + } + + // Ratings + for (const r of (ratingsRes.data ?? []) as { storyline_id: number; rating: number; created_at: string }[]) { + entries.push({ + type: "rated", + timestamp: r.created_at, + storylineId: r.storyline_id, + detail: `${"★".repeat(r.rating)}${"☆".repeat(5 - r.rating)}`, + }); + } + + // TODO: Claimed royalties feed entries require a dedicated claim event + // indexer. For now, cumulative claimed amount is shown in the profile header + // via on-chain getRoyaltyInfo. + + // Sort reverse-chronological + entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + return entries; }, }); - const isLoading = donLoading || ratLoading; - const hasActivity = donations.length > 0 || ratings.length > 0; - if (isLoading) return

Loading...

; - if (!hasActivity) { - return

No activity yet.

; + if (feed.length === 0) { + return ( +
+

No activity yet.

+

+ This address has no on-chain activity on PlotLink. +

+
+ ); } + const visible = feed.slice(0, visibleCount); + const hasMore = visibleCount < feed.length; + return ( -
- {donations.length > 0 && ( -
-

Donations

-
- {donations.map((d) => ( -
-
- - Story #{d.storyline_id} - - {d.block_timestamp && ( - - )} -
-
- - {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL} - - {d.tx_hash && ( - - ↗ - - )} -
-
- ))} -
-
+
+
+ {visible.map((entry, i) => ( + + ))} +
+ {hasMore && ( + )} +
+ ); +} - {ratings.length > 0 && ( -
-

Ratings

-
- {ratings.map((r: { id: number; storyline_id: number; rating: number; comment: string | null; created_at: string }) => ( -
-
- - Story #{r.storyline_id} - - {"★".repeat(r.rating)}{"☆".repeat(5 - r.rating)} -
- -
- ))} -
-
- )} +const EVENT_LABELS: Record = { + created_storyline: "Created", + published_plot: "Published", + bought: "Bought", + sold: "Sold", + donated: "Donated", + rated: "Rated", + claimed_royalties: "Claimed", +}; + +const EVENT_COLORS: Record = { + created_storyline: "text-accent", + published_plot: "text-accent", + bought: "text-green-700", + sold: "text-red-700", + donated: "text-accent", + rated: "text-muted", + claimed_royalties: "text-green-700", +}; + +function FeedRow({ entry }: { entry: FeedEntry }) { + return ( +
+
+ + {EVENT_LABELS[entry.type]} + + {entry.storylineId > 0 ? ( + + {entry.storyTitle ?? `Story #${entry.storylineId}`} + + ) : ( + Royalties + )} + {entry.detail && ( + {entry.detail} + )} +
+
+ + {entry.txHash && ( + + ↗ + + )} +
); }