diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index ff2415ac..93258074 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -1,64 +1,19 @@ "use client"; -import { useState } from "react"; -import { useAccount, useSignMessage } from "wagmi"; -import { useQuery, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; -import { formatUnits } from "viem"; -import { supabase, type Storyline, type Donation } from "../../../../lib/supabase"; -import { getTokenTVL } from "../../../../lib/price"; -import { browserClient } from "../../../../lib/rpc"; -import { RESERVE_LABEL, STORY_FACTORY, EXPLORER_URL } from "../../../../lib/contracts/constants"; -import { GENRES, LANGUAGES } from "../../../../lib/genres"; -import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; -import { ClaimRoyalties } from "../../../components/ClaimRoyalties"; -import { WriterTradingStats } from "../../../components/WriterTradingStats"; -import { WriterIdentityClient } from "../../../components/WriterIdentityClient"; -import { DropdownSelect } from "../../../components/DropdownSelect"; -import { truncateAddress } from "../../../../lib/utils"; -import { formatPrice } from "../../../../lib/format"; -import Link from "next/link"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAccount } from "wagmi"; import { ConnectWallet } from "../../../components/ConnectWallet"; -import { usePlotUsdPrice } from "../../../hooks/usePlotUsdPrice"; -import { type Address } from "viem"; -function formatViewCountDashboard(n: number): string { - if (n < 1000) return String(n); - if (n < 10000) return `${(n / 1000).toFixed(1)}k`; - if (n < 1000000) return `${Math.round(n / 1000)}k`; - return `${(n / 1000000).toFixed(1)}M`; -} - -async function fetchWriterStorylines( - address: string, -): Promise { - if (!supabase) return []; - const { data, error } = await supabase - .from("storylines") - .select("*") - .eq("writer_address", address.toLowerCase()) - .eq("hidden", false) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .returns(); - if (error) throw error; - return data ?? []; -} - -const genreOptions = [ - { value: "", label: "Select genre..." }, - ...GENRES.map((g) => ({ value: g, label: g })), -]; -const languageOptions = LANGUAGES.map((l) => ({ value: l, label: l })); - -export default function WriterDashboard() { +export default function WriterRedirect() { + const router = useRouter(); const { address, isConnected } = useAccount(); - const { data: plotUsd } = usePlotUsdPrice(); - const { data: storylines = [], isLoading, error } = useQuery({ - queryKey: ["writer-storylines", address], - queryFn: () => fetchWriterStorylines(address!), - enabled: isConnected && !!address, - }); + useEffect(() => { + if (isConnected && address) { + router.replace(`/profile/${address}?tab=stories`); + } + }, [isConnected, address, router]); if (!isConnected) { return ( @@ -72,364 +27,8 @@ export default function WriterDashboard() { } return ( -
-

- Writer Dashboard -

-

- - {" — "} - {storylines.length}{" "} - {storylines.length === 1 ? "storyline" : "storylines"} -

- - {isLoading &&

Loading...

} - - {error && ( -

- Failed to load storylines. Please try again. -

- )} - -
- {storylines.map((s) => ( - - ))} - {!isLoading && !error && storylines.length === 0 && ( -

- No storylines yet. -

- )} -
-
- ); -} - -function StorylineDetail({ storyline, writerAddress, plotUsd }: { storyline: Storyline; writerAddress: Address; plotUsd?: number | null }) { - return ( -
-
- - {storyline.title} - - {storyline.sunset && ( - - complete - - )} -
- - {!storyline.genre && ( - - )} - -
-
- - Plots - - {storyline.plot_count} -
-
- - Views - - {formatViewCountDashboard(storyline.view_count)} -
-
- - Created - - - {storyline.block_timestamp - ? new Date(storyline.block_timestamp).toLocaleDateString( - "en-US", - { month: "short", day: "numeric", year: "numeric" }, - ) - : "—"} - -
-
- - Donations - - - {storyline.token_address - ? - : - } -
-
- - {!storyline.sunset && storyline.last_plot_time && ( - - )} - - {storyline.token_address && ( -
- - -
- )} - - +
+

Redirecting to profile...

); } - -const DONATION_PAGE_SIZE = 10; - -function WriterDonationHistory({ storylineId }: { storylineId: number }) { - const { - data, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - } = useInfiniteQuery({ - queryKey: ["writer-donations", storylineId], - queryFn: async ({ pageParam = 0 }) => { - if (!supabase) return { rows: [] as Donation[], totalCount: 0 }; - const { data: rows, count } = await supabase - .from("donations") - .select("*", { count: "exact" }) - .eq("storyline_id", storylineId) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .range(pageParam, pageParam + DONATION_PAGE_SIZE - 1) - .returns(); - return { rows: rows ?? [], totalCount: count ?? 0 }; - }, - initialPageParam: 0, - getNextPageParam: (_lastPage, allPages) => { - const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); - const totalCount = allPages[0]?.totalCount ?? 0; - return totalFetched < totalCount ? totalFetched : undefined; - }, - }); - - const donations = data?.pages.flatMap((p) => p.rows) ?? []; - const totalCount = data?.pages[0]?.totalCount ?? 0; - - if (donations.length === 0) return null; - - return ( -
- - Donation History - -
- {donations.map((d) => ( -
-
- - {truncateAddress(d.donor_address)} - - {d.block_timestamp && ( - - )} -
-
- - {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL} - - {d.tx_hash && ( - - ↗ - - )} -
-
- ))} -
- {hasNextPage && ( - - )} -
- ); -} - -function GenrePrompt({ - storylineId, - language, - writerAddress, -}: { - storylineId: number; - language: string; - writerAddress: string; -}) { - const [genre, setGenre] = useState(""); - const [lang, setLang] = useState(language || "English"); - const [saving, setSaving] = useState(false); - const [err, setErr] = useState(null); - const queryClient = useQueryClient(); - const { signMessageAsync } = useSignMessage(); - - async function handleSave() { - if (!genre) return; - setSaving(true); - setErr(null); - try { - const langValue = language ? "" : lang; - const message = `Update storyline ${storylineId} metadata genre:${genre} language:${langValue}`; - const signature = await signMessageAsync({ message }); - - const res = await fetch(`/api/storyline/${storylineId}/metadata`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - genre, - ...(language ? {} : { language: lang }), - address: writerAddress, - signature, - message, - }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || `Error (${res.status})`); - } - queryClient.invalidateQueries({ queryKey: ["writer-storylines"] }); - } catch (e) { - setErr(e instanceof Error ? e.message : "Failed to save"); - } finally { - setSaving(false); - } - } - - return ( -
-

- Set your genre - - {" — "}improve discoverability by categorizing your story. - -

-
-
- -
- {!language && ( -
- -
- )} - -
- {err &&

{err}

} -
- ); -} - -function DonationCount({ storylineId, tokenAddress }: { storylineId: number; tokenAddress: string }) { - const { data } = useQuery({ - queryKey: ["donation-count", storylineId, tokenAddress], - queryFn: async () => { - const [tvlData, rows] = await Promise.all([ - getTokenTVL(tokenAddress as Address, browserClient), - supabase - ? supabase.from("donations") - .select("amount") - .eq("storyline_id", storylineId) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .then((r: { data: { amount: string }[] | null }) => r.data) - : null, - ]); - const decimals = tvlData?.decimals ?? 18; - if (!rows || rows.length === 0) return { total: BigInt(0), count: 0, decimals }; - const total = (rows as { amount: string }[]).reduce( - (sum, d) => sum + BigInt(d.amount), - BigInt(0), - ); - return { total, count: rows.length, decimals }; - }, - }); - - if (!data || data.count === 0) { - return ; - } - - return ( - - {formatUnits(data.total, data.decimals)} {RESERVE_LABEL} ({data.count}) - - ); -} - -function DonationsTooltip() { - const [show, setShow] = useState(false); - return ( - - - {show && ( - - Donations -
- - Sent directly to your wallet when donors contribute. Already in your account — no claiming needed. - -
- )} -
- ); -} diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index dcccc236..b335a136 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1,9 +1,9 @@ "use client"; import { useState, useCallback, useEffect } from "react"; -import { useParams } from "next/navigation"; -import { useAccount } from "wagmi"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams, useSearchParams } from "next/navigation"; +import { useAccount, useSignMessage } from "wagmi"; +import { useQuery, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import Link from "next/link"; import { supabase, type Storyline, type Donation, type TradeHistory, type User } from "../../../../lib/supabase"; @@ -18,6 +18,11 @@ import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; import { usePlotUsdPrice } from "../../../hooks/usePlotUsdPrice"; import { formatUsdValue } from "../../../../lib/usd-price"; import { DisconnectButton } from "../../../components/ConnectWallet"; +import { GENRES, LANGUAGES } from "../../../../lib/genres"; +import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; +import { ClaimRoyalties } from "../../../components/ClaimRoyalties"; +import { WriterTradingStats } from "../../../components/WriterTradingStats"; +import { DropdownSelect } from "../../../components/DropdownSelect"; type Tab = "stories" | "portfolio" | "activity"; @@ -29,7 +34,11 @@ export default function ProfilePage() { const isOwnProfile = connectedAddress?.toLowerCase() === address; const queryClient = useQueryClient(); - const [tab, setTab] = useState("stories"); + const searchParams = useSearchParams(); + const initialTab = (searchParams.get("tab") as Tab) || "stories"; + const [tab, setTab] = useState( + ["stories", "portfolio", "activity"].includes(initialTab) ? initialTab : "stories" + ); // Unified profile fetch: single DB lookup, derives FC + agent from shared result const { data: fullProfile, isLoading: profileLoading } = useQuery({ @@ -157,6 +166,7 @@ export default function ProfilePage() { isAgent={isAgent} agentMeta={agentMeta ?? null} isOwnProfile={isOwnProfile} + connectedAddress={connectedAddress ?? null} /> )} {tab === "portfolio" && } @@ -436,7 +446,7 @@ function ProfileHeader({
- {claimedRoyalties && claimedRoyalties > BigInt(0) && ( + {claimedRoyalties != null && claimedRoyalties > BigInt(0) && (
Royalties: {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL}
@@ -501,12 +511,15 @@ function StoriesTab({ isAgent, agentMeta, isOwnProfile, + connectedAddress, }: { address: string; isAgent: boolean; agentMeta: AgentMetadata | null; isOwnProfile: boolean; + connectedAddress: string | null; }) { + const { data: plotUsd } = usePlotUsdPrice(); const { data: storylines = [], isLoading, error } = useQuery({ queryKey: ["profile-storylines", address], queryFn: async () => { @@ -709,7 +722,13 @@ function StoriesTab({ {/* Story portfolio */}
{storylines.map((s) => ( - + ))}
@@ -725,7 +744,17 @@ function StatCell({ label, value }: { label: string; value: string }) { ); } -function StoryRow({ storyline }: { storyline: Storyline }) { +function StoryRow({ + storyline, + isOwnProfile, + writerAddress, + plotUsd, +}: { + storyline: Storyline; + isOwnProfile: boolean; + writerAddress: Address; + plotUsd?: number | null; +}) { const tokenAddr = storyline.token_address as Address; const { data: priceInfo } = useQuery({ @@ -799,7 +828,7 @@ function StoryRow({ storyline }: { storyline: Storyline }) { -
+
{storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} @@ -814,6 +843,9 @@ function StoryRow({ storyline }: { storyline: Storyline }) { {formatViewCount(storyline.view_count)} views + {isOwnProfile && storyline.token_address && ( + + )}
{storyline.block_timestamp && ( @@ -826,10 +858,274 @@ function StoryRow({ storyline }: { storyline: Storyline }) { })}
)} + + {/* Genre prompt — own profile only, when genre not set */} + {isOwnProfile && !storyline.genre && ( + + )} + + {/* Deadline countdown — own profile only, active storylines */} + {isOwnProfile && !storyline.sunset && storyline.last_plot_time && ( + + )} + + {/* Token price + TVL — visible to all */} + {storyline.token_address && ( +
+ + {/* Claim royalties — own profile only */} + {isOwnProfile && ( + + )} +
+ )} + + {/* Donation history — own profile only */} + {isOwnProfile && storyline.token_address && ( + + )} ); } +// --------------------------------------------------------------------------- +// Genre Prompt — for setting genre on own profile storylines +// --------------------------------------------------------------------------- + +const genreOptions = [ + { value: "", label: "Select genre..." }, + ...GENRES.map((g) => ({ value: g, label: g })), +]; +const languageOptions = LANGUAGES.map((l) => ({ value: l, label: l })); + +function GenrePrompt({ + storylineId, + language, + writerAddress, +}: { + storylineId: number; + language: string; + writerAddress: string; +}) { + const [genre, setGenre] = useState(""); + const [lang, setLang] = useState(language || "English"); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + const queryClient = useQueryClient(); + const { signMessageAsync } = useSignMessage(); + + async function handleSave() { + if (!genre) return; + setSaving(true); + setErr(null); + try { + const langValue = language ? "" : lang; + const message = `Update storyline ${storylineId} metadata genre:${genre} language:${langValue}`; + const signature = await signMessageAsync({ message }); + + const res = await fetch(`/api/storyline/${storylineId}/metadata`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + genre, + ...(language ? {} : { language: lang }), + address: writerAddress, + signature, + message, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Error (${res.status})`); + } + queryClient.invalidateQueries({ queryKey: ["profile-storylines"] }); + } catch (e) { + setErr(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + return ( +
+

+ Set your genre + + {" — "}improve discoverability by categorizing your story. + +

+
+
+ +
+ {!language && ( +
+ +
+ )} + +
+ {err &&

{err}

} +
+ ); +} + +// --------------------------------------------------------------------------- +// Donation History — own profile storyline donations +// --------------------------------------------------------------------------- + +const DONATION_PAGE_SIZE = 10; + +function ProfileDonationHistory({ storylineId }: { storylineId: number }) { + const { + data, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery({ + queryKey: ["profile-donations", storylineId], + queryFn: async ({ pageParam = 0 }) => { + if (!supabase) return { rows: [] as Donation[], totalCount: 0 }; + const { data: rows, count } = await supabase + .from("donations") + .select("*", { count: "exact" }) + .eq("storyline_id", storylineId) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .range(pageParam, pageParam + DONATION_PAGE_SIZE - 1) + .returns(); + return { rows: rows ?? [], totalCount: count ?? 0 }; + }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => { + const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); + const totalCount = allPages[0]?.totalCount ?? 0; + return totalFetched < totalCount ? totalFetched : undefined; + }, + }); + + const donations = data?.pages.flatMap((p) => p.rows) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; + + if (donations.length === 0) return null; + + return ( +
+ + Donation History + +
+ {donations.map((d) => ( +
+
+ + {truncateAddress(d.donor_address)} + + {d.block_timestamp && ( + + )} +
+
+ + {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL} + + {d.tx_hash && ( + + ↗ + + )} +
+
+ ))} +
+ {hasNextPage && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Donation Count — inline stat for own profile story cards +// --------------------------------------------------------------------------- + +function StoryDonationCount({ storylineId, tokenAddress }: { storylineId: number; tokenAddress: string }) { + const { data } = useQuery({ + queryKey: ["story-donation-count", storylineId, tokenAddress], + queryFn: async () => { + if (!supabase) return { total: BigInt(0), count: 0 }; + const rows = await supabase + .from("donations") + .select("amount") + .eq("storyline_id", storylineId) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .then((r: { data: { amount: string }[] | null }) => r.data); + if (!rows || rows.length === 0) return { total: BigInt(0), count: 0 }; + const total = rows.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + return { total, count: rows.length }; + }, + }); + + if (!data || data.count === 0) { + return — donations; + } + + return ( + + {formatPrice(formatUnits(data.total, 18))} {RESERVE_LABEL} ({data.count}) + + ); +} + // --------------------------------------------------------------------------- // Portfolio Tab // ---------------------------------------------------------------------------