Skip to content
Merged
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
9 changes: 2 additions & 7 deletions src/app/dashboard/reader/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { supabase, type Donation } from "../../../../lib/supabase";
import { ConnectWallet } from "../../../components/ConnectWallet";
import { ReaderPortfolio } from "../../../components/ReaderPortfolio";
import { formatUnits } from "viem";

const PAGE_SIZE = 50;
Expand Down Expand Up @@ -76,13 +77,7 @@ 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>
<ReaderPortfolio />

{/* --- Donation History --- */}
<section className="mt-8">
Expand Down
12 changes: 8 additions & 4 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 @@ -130,10 +131,13 @@ function StorylineDetail({ storyline }: { storyline: Storyline }) {
)}

{storyline.token_address && (
<ClaimRoyalties
tokenAddress={storyline.token_address as Address}
plotCount={storyline.plot_count}
/>
<>
<WriterTradingStats storyline={storyline} />
<ClaimRoyalties
tokenAddress={storyline.token_address as Address}
plotCount={storyline.plot_count}
/>
</>
)}
</div>
);
Expand Down
177 changes: 177 additions & 0 deletions src/components/ReaderPortfolio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

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

interface Holding {
storyline: Storyline;
balance: bigint;
price: bigint;
value: bigint;
priceChange: number | null;
reserveDecimals: number;
}

export function ReaderPortfolio() {
const { address, isConnected } = useAccount();
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";

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

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

if (!storylines || storylines.length === 0) return [];

// Check balance for each token (parallel)
const results = await Promise.all(
storylines.map(async (sl): Promise<Holding | null> => {
const tokenAddr = sl.token_address as Address;
try {
const balance = await publicClient.readContract({
address: tokenAddr,
abi: erc20Abi,
functionName: "balanceOf",
args: [address],
});

if (balance === BigInt(0)) return null;

const [price, priceChangeResult, tvlResult] = await Promise.all([
publicClient.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
functionName: "priceForNextMint",
args: [tokenAddr],
}),
get24hPriceChange(tokenAddr).catch(() => null),
getTokenTVL(tokenAddr).catch(() => null),
]);

const priceBI = BigInt(price);
const reserveDecimals = tvlResult?.decimals ?? 18;
const value = (balance * priceBI) / BigInt(10 ** 18);

return {
storyline: sl,
balance,
price: priceBI,
value,
priceChange: priceChangeResult?.changePercent ?? null,
reserveDecimals,
};
} catch {
return null;
}
}),
);

return results.filter((h): h is Holding => h !== null);
},
enabled: isConnected && !!address,
});

const totalValue = holdings?.reduce((sum, h) => sum + h.value, BigInt(0)) ?? BigInt(0);
const reserveDecimals = holdings && holdings.length > 0 ? holdings[0].reserveDecimals : 18;
const bestPick = holdings && holdings.length > 0
? holdings.reduce((best, h) =>
(h.priceChange ?? -Infinity) > (best.priceChange ?? -Infinity) ? h : best
)
: null;

if (!isConnected) return 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 && holdings && holdings.length === 0 && (
<p className="text-muted mt-2 text-xs">
No token holdings found. Buy storyline tokens to build your portfolio.
</p>
)}

{holdings && holdings.length > 0 && (
<>
<div className="text-muted mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<span className="block text-[10px] uppercase tracking-wider">
Total Value
</span>
<span className="text-accent text-sm font-medium">
{formatUnits(totalValue, reserveDecimals)} {reserveLabel}
</span>
</div>
{bestPick && bestPick.priceChange !== null && (
<div>
<span className="block text-[10px] uppercase tracking-wider">
Best Pick (24h)
</span>
<span className="text-foreground">
{bestPick.storyline.title.slice(0, 20)}
{bestPick.storyline.title.length > 20 ? "..." : ""}{" "}
<span className={bestPick.priceChange >= 0 ? "text-accent" : "text-red-400"}>
{bestPick.priceChange >= 0 ? "+" : ""}
{bestPick.priceChange.toFixed(1)}%
</span>
</span>
</div>
)}
</div>

<div className="mt-4 space-y-2">
{holdings.map((h) => (
<div
key={h.storyline.id}
className="border-border flex items-center justify-between rounded border px-3 py-2 text-xs"
>
<div>
<Link
href={`/story/${h.storyline.storyline_id}`}
className="text-foreground hover:text-accent transition-colors"
>
{h.storyline.title}
</Link>
<div className="text-muted mt-0.5">
{formatUnits(h.balance, 18)} tokens
</div>
</div>
<div className="text-right">
<div className="text-foreground">
{formatUnits(h.value, h.reserveDecimals)} {reserveLabel}
</div>
{h.priceChange !== null && (
<div
className={`text-[10px] ${h.priceChange >= 0 ? "text-accent" : "text-red-400"}`}
>
{h.priceChange >= 0 ? "+" : ""}
{h.priceChange.toFixed(1)}%
</div>
)}
</div>
</div>
))}
</div>
</>
)}
</section>
);
}
109 changes: 109 additions & 0 deletions src/components/WriterTradingStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

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

interface WriterTradingStatsProps {
storyline: Storyline;
}

export function WriterTradingStats({ storyline }: WriterTradingStatsProps) {
const tokenAddress = storyline.token_address as Address;
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";

// Fetch token price
const { data: price } = useQuery({
queryKey: ["writer-price", tokenAddress],
queryFn: async () => {
const result = await publicClient.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
functionName: "priceForNextMint",
args: [tokenAddress],
});
return result;
},
enabled: !!tokenAddress,
});

// Fetch TVL via getTokenTVL (uses correct reserve token decimals)
const { data: tvlData } = useQuery({
queryKey: ["writer-tvl", tokenAddress],
queryFn: () => getTokenTVL(tokenAddress),
enabled: !!tokenAddress,
});

// Fetch unclaimed royalties
const { data: royaltyData } = useQuery({
queryKey: ["writer-royalty", tokenAddress],
queryFn: async () => {
const result = await publicClient.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
functionName: "getRoyaltyInfo",
args: [tokenAddress],
});
return { unclaimed: result[0] };
},
enabled: !!tokenAddress,
});

// Fetch total donations for this storyline
const { data: donationsTotal } = useQuery({
queryKey: ["writer-donations", storyline.storyline_id],
queryFn: async () => {
if (!supabase) return BigInt(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data } = await (supabase.from("donations") as any)
.select("amount")
.eq("storyline_id", storyline.storyline_id);
if (!data) return BigInt(0);
return (data as { amount: string }[]).reduce((sum, d) => sum + BigInt(d.amount), BigInt(0));
},
});

const earnings =
donationsTotal !== undefined && royaltyData
? donationsTotal + royaltyData.unclaimed
: undefined;

return (
<div className="text-muted mt-3 grid grid-cols-3 gap-2 text-xs">
<div>
<span className="block text-[10px] uppercase tracking-wider">
Earnings
</span>
<span className="text-accent font-medium">
{earnings !== undefined
? `${formatUnits(earnings, 18)} ${reserveLabel}`
: "—"}
</span>
<span className="text-muted block text-[10px]">
{donationsTotal !== undefined && `D: ${formatUnits(donationsTotal, 18)}`}
{royaltyData && ` R: ${formatUnits(royaltyData.unclaimed, 18)}`}
</span>
</div>
<div>
<span className="block text-[10px] uppercase tracking-wider">
Token Price
</span>
<span className="text-foreground">
{price !== undefined ? `${formatUnits(BigInt(price), 18)} ${reserveLabel}` : "—"}
</span>
</div>
<div>
<span className="block text-[10px] uppercase tracking-wider">
TVL
</span>
<span className="text-foreground">
{tvlData ? `${tvlData.tvl} ${reserveLabel}` : "—"}
</span>
</div>
</div>
);
}
Loading