Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/app/dashboard/reader/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { supabase, type Donation } from "../../../../lib/supabase";
import { ConnectWallet } from "../../../components/ConnectWallet";
import { formatUnits } from "viem";
import { ReaderPortfolio } from "../../../components/ReaderPortfolio";
import { formatUnits, type Address } from "viem";

const PAGE_SIZE = 50;

Expand Down Expand Up @@ -76,13 +77,8 @@ export default function ReaderDashboard() {
Reader Dashboard
</h1>

{/* --- Portfolio section (Phase 5) --- */}
<section className="border-border mt-8 rounded border px-4 py-4">
<h2 className="text-foreground text-sm font-medium">Portfolio</h2>
<p className="text-muted mt-2 text-xs italic">
Token holdings and portfolio value available after Phase 5 (P5-7b).
</p>
</section>
{/* --- Portfolio section --- */}
<ReaderPortfolio readerAddress={address as Address} />

{/* --- Donation History --- */}
<section className="mt-8">
Expand Down
13 changes: 13 additions & 0 deletions src/app/dashboard/writer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -62,6 +63,18 @@ export default function WriterDashboard() {
</p>
)}

{isConnected && address && storylines.length > 0 && (
<WriterTradingStats
writerAddress={address as Address}
storylineTokens={storylines
.filter((s) => s.token_address)
.map((s) => ({
storylineId: s.storyline_id,
tokenAddress: s.token_address as Address,
}))}
/>
)}

<div className="mt-8 space-y-4">
{storylines.map((s) => (
<StorylineDetail key={s.id} storyline={s} />
Expand Down
153 changes: 153 additions & 0 deletions src/components/ReaderPortfolio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { formatUnits, type Address } from "viem";
import { publicClient } from "../../lib/rpc";
import { erc20Abi, mcv2BondAbi } from "../../lib/price";
import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants";
import { supabase, type Storyline } from "../../lib/supabase";

interface ReaderPortfolioProps {
readerAddress: Address;
}

interface Holding {
storylineId: number;
title: string;
balance: bigint;
pricePerToken: bigint;
value: bigint;
}

export function ReaderPortfolio({ readerAddress }: ReaderPortfolioProps) {
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";

const { data: holdings, isLoading } = useQuery({
queryKey: ["reader-portfolio", readerAddress],
queryFn: async () => {
if (!supabase) return [];

// Fetch all storylines with token addresses
const { data: storylines } = await supabase
.from("storylines")
.select("*")
.eq("hidden", false)
.returns<Storyline[]>();

if (!storylines) return [];

const withTokens = storylines.filter((s) => s.token_address);
const results: Holding[] = [];

// Check balance for each token
for (const s of withTokens) {
try {
const balance = await publicClient.readContract({
address: s.token_address as Address,
abi: erc20Abi,
functionName: "balanceOf",
args: [readerAddress],
});

if (balance > BigInt(0)) {
// Get current price for value calculation
let pricePerToken = BigInt(0);
try {
const oneToken = BigInt(10 ** 18);
pricePerToken = await publicClient.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
functionName: "getReserveForToken",
args: [s.token_address as Address, oneToken],
});
} catch {
// Price unavailable
}

const value =
pricePerToken > BigInt(0)
? (balance * pricePerToken) / BigInt(10 ** 18)
: BigInt(0);

results.push({
storylineId: s.storyline_id,
title: s.title,
balance,
pricePerToken,
value,
});
}
} catch {
// Skip tokens that fail
}
}

return results;
},
});

const allHoldings = holdings ?? [];
const totalValue = allHoldings.reduce((sum, h) => sum + h.value, BigInt(0));
const bestPick =
allHoldings.length > 0
? allHoldings.reduce((best, h) => (h.value > best.value ? h : best))
: null;

return (
<section className="border-border mt-8 rounded border px-4 py-4">
<h2 className="text-foreground text-sm font-medium">Portfolio</h2>

{isLoading && (
<p className="text-muted mt-2 text-xs">Loading holdings...</p>
)}

{!isLoading && allHoldings.length === 0 && (
<p className="text-muted mt-2 text-xs italic">
No token holdings found.
</p>
)}

{allHoldings.length > 0 && (
<>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-muted block text-[10px] uppercase tracking-wider">
Portfolio Value
</span>
<span className="text-foreground">
{formatUnits(totalValue, 18)} {reserveLabel}
</span>
</div>
{bestPick && (
<div>
<span className="text-muted block text-[10px] uppercase tracking-wider">
Best Pick
</span>
<span className="text-foreground">{bestPick.title}</span>
</div>
)}
</div>

<div className="mt-3 space-y-1">
{allHoldings.map((h) => (
<div
key={h.storylineId}
className="text-muted flex justify-between text-[10px]"
>
<span>{h.title}</span>
<span className="text-foreground">
{formatUnits(h.balance, 18)} tokens
{h.value > BigInt(0) && (
<span className="text-muted ml-1">
({formatUnits(h.value, 18)} {reserveLabel})
</span>
)}
</span>
</div>
))}
</div>
</>
)}
</section>
);
}
191 changes: 191 additions & 0 deletions src/components/WriterTradingStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { formatUnits, type Address } from "viem";
import { publicClient } from "../../lib/rpc";
import { erc20Abi, mcv2BondAbi } from "../../lib/price";
import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants";
import { supabase, type Donation } from "../../lib/supabase";

interface WriterTradingStatsProps {
writerAddress: Address;
storylineTokens: { storylineId: number; tokenAddress: Address }[];
}

interface StoryStats {
storylineId: number;
totalSupply: bigint;
holderCount: number;
}

export function WriterTradingStats({
writerAddress,
storylineTokens,
}: WriterTradingStatsProps) {
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";

const storyIds = storylineTokens.map((t) => t.storylineId);

// Fetch total donations received for this writer's storylines only
const { data: totalDonations } = useQuery({
queryKey: ["writer-total-donations", writerAddress, storyIds],
queryFn: async () => {
if (!supabase || storyIds.length === 0) return BigInt(0);
const { data } = await supabase
.from("donations")
.select("amount")
.in("storyline_id", storyIds)
.returns<Pick<Donation, "amount">[]>();
if (!data) return BigInt(0);
return data.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0));
},
enabled: storyIds.length > 0,
});

// Fetch per-story trading volume (totalSupply) and holder count
const { data: storyStats } = useQuery({
queryKey: ["writer-story-stats", storylineTokens.map((t) => t.tokenAddress)],
queryFn: async () => {
// Get unique donor counts per storyline as holder proxy
const donorCounts = new Map<number, number>();
if (supabase && storyIds.length > 0) {
const { data: donors } = await supabase
.from("donations")
.select("storyline_id, donor_address")
.in("storyline_id", storyIds)
.returns<Pick<Donation, "storyline_id" | "donor_address">[]>();
if (donors) {
const perStory = new Map<number, Set<string>>();
for (const d of donors) {
const set = perStory.get(d.storyline_id) ?? new Set<string>();
set.add(d.donor_address);
perStory.set(d.storyline_id, set);
}
for (const [id, set] of perStory) {
donorCounts.set(id, set.size);
}
}
}

const results: StoryStats[] = [];
for (const t of storylineTokens) {
try {
const supply = await publicClient.readContract({
address: t.tokenAddress,
abi: erc20Abi,
functionName: "totalSupply",
});
results.push({
storylineId: t.storylineId,
totalSupply: supply,
holderCount: donorCounts.get(t.storylineId) ?? 0,
});
} catch {
results.push({
storylineId: t.storylineId,
totalSupply: BigInt(0),
holderCount: donorCounts.get(t.storylineId) ?? 0,
});
}
}
return results;
},
enabled: storylineTokens.length > 0,
});

// Fetch unclaimed royalties across all storylines
const { data: totalRoyalties } = useQuery({
queryKey: ["writer-total-royalties", storylineTokens.map((t) => t.tokenAddress)],
queryFn: async () => {
let total = BigInt(0);
for (const t of storylineTokens) {
try {
const result = await publicClient.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
functionName: "getRoyaltyInfo",
args: [t.tokenAddress],
});
total += result[0];
} catch {
// Skip on error
}
}
return total;
},
enabled: storylineTokens.length > 0,
});

if (storylineTokens.length === 0) return null;

const totalVolume = (storyStats ?? []).reduce(
(sum, s) => sum + s.totalSupply,
BigInt(0),
);

return (
<section className="border-border mt-8 rounded border px-4 py-4">
<h2 className="text-foreground text-sm font-medium">Trading Stats</h2>
<p className="text-muted mt-1 text-[10px] italic">
Lifetime total earned requires a royalty-claim indexer — showing available metrics.
</p>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-muted block text-[10px] uppercase tracking-wider">
Donations Received
</span>
<span className="text-foreground">
{formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel}
</span>
</div>
<div>
<span className="text-muted block text-[10px] uppercase tracking-wider">
Unclaimed Royalties
</span>
<span className="text-foreground">
{formatUnits(totalRoyalties ?? BigInt(0), 18)} {reserveLabel}
</span>
</div>
<div>
<span className="text-muted block text-[10px] uppercase tracking-wider">
Total Tokens Minted
</span>
<span className="text-foreground">
{formatUnits(totalVolume, 18)}
</span>
</div>
<div>
<span className="text-muted block text-[10px] uppercase tracking-wider">
Unique Holders
</span>
<span className="text-foreground">
{(storyStats ?? []).reduce((sum, s) => sum + s.holderCount, 0)}
</span>
</div>
</div>

{storyStats && storyStats.length > 0 && (
<div className="mt-3 space-y-1">
{storyStats
.filter((s) => s.totalSupply > BigInt(0))
.map((s) => (
<div
key={s.storylineId}
className="text-muted flex justify-between text-[10px]"
>
<span>Story #{s.storylineId}</span>
<span className="text-foreground">
{formatUnits(s.totalSupply, 18)} minted
{s.holderCount > 0 && (
<span className="text-muted ml-1">
&middot; {s.holderCount} holders
</span>
)}
</span>
</div>
))}
</div>
)}
</section>
);
}
Loading