From 1824424612d2d9996bb47fa6782f47eb3385375d Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:03:28 +0000 Subject: [PATCH 1/2] [#26] Add donation flow to story page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #26 - P5-4a: New DonateWidget component — amount input, PLOT_TOKEN approval, StoryFactory.donate() call, donation indexer trigger - Full tx state UI: approving → confirming → pending → indexing → done - All addresses from lib/contracts/constants.ts - Placed after trading widget on story page Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/page.tsx | 2 + src/components/DonateWidget.tsx | 155 +++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/components/DonateWidget.tsx diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 47542dae..b6e214e7 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -2,6 +2,7 @@ import { createServerClient, type Storyline, type Plot } from "../../../../lib/s import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; import { TradingWidget } from "../../../components/TradingWidget"; import { PriceChart } from "../../../components/PriceChart"; +import { DonateWidget } from "../../../components/DonateWidget"; import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price"; import { IS_TESTNET } from "../../../../lib/contracts/constants"; import { type Address } from "viem"; @@ -64,6 +65,7 @@ export default async function StoryPage({ params }: { params: Params }) { {sl.token_address && ( )} +
{plots.map((plot) => ( diff --git a/src/components/DonateWidget.tsx b/src/components/DonateWidget.tsx new file mode 100644 index 00000000..adbba660 --- /dev/null +++ b/src/components/DonateWidget.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useAccount, useWriteContract } from "wagmi"; +import { parseUnits, formatUnits } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { erc20Abi } from "../../lib/price"; +import { storyFactoryAbi } from "../../lib/contracts/abi"; +import { STORY_FACTORY, PLOT_TOKEN, IS_TESTNET } from "../../lib/contracts/constants"; + +type TxState = "idle" | "approving" | "confirming" | "pending" | "indexing" | "done" | "error"; + +interface DonateWidgetProps { + storylineId: number; +} + +export function DonateWidget({ storylineId }: DonateWidgetProps) { + const { address, isConnected } = useAccount(); + const [amount, setAmount] = useState(""); + const [txState, setTxState] = useState("idle"); + const [error, setError] = useState(null); + const [txHash, setTxHash] = useState(null); + + const { writeContractAsync } = useWriteContract(); + + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const parsedAmount = + amount && !isNaN(Number(amount)) && Number(amount) > 0 + ? parseUnits(amount, 18) + : BigInt(0); + + const executeDonate = useCallback(async () => { + if (!address || parsedAmount === BigInt(0)) return; + + try { + setError(null); + setTxHash(null); + + // Check allowance for PLOT_TOKEN → StoryFactory + const allowance = await publicClient.readContract({ + address: PLOT_TOKEN, + abi: erc20Abi, + functionName: "allowance", + args: [address, STORY_FACTORY], + }); + + if (allowance < parsedAmount) { + setTxState("approving"); + const approveHash = await writeContractAsync({ + address: PLOT_TOKEN, + abi: erc20Abi, + functionName: "approve", + args: [STORY_FACTORY, parsedAmount], + }); + await publicClient.waitForTransactionReceipt({ hash: approveHash }); + } + + // Call donate() + setTxState("confirming"); + const hash = await writeContractAsync({ + address: STORY_FACTORY, + abi: storyFactoryAbi, + functionName: "donate", + args: [BigInt(storylineId), parsedAmount], + }); + setTxHash(hash); + + setTxState("pending"); + await publicClient.waitForTransactionReceipt({ hash }); + + // Trigger donation indexer + setTxState("indexing"); + await fetch("/api/index/donation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ txHash: hash }), + }); + + setTxState("done"); + setAmount(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Transaction failed"); + setTxState("error"); + } + }, [address, parsedAmount, storylineId, writeContractAsync]); + + const reset = useCallback(() => { + setTxState("idle"); + setError(null); + setTxHash(null); + }, []); + + if (!isConnected) return null; + + return ( +
+

Donate to Writer

+

+ Tip the author directly with {reserveLabel} +

+ +
+ + { + setAmount(e.target.value); + if (txState !== "idle") reset(); + }} + disabled={txState !== "idle" && txState !== "error" && txState !== "done"} + className="border-border bg-background text-foreground mt-1 w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50" + /> +
+ + {parsedAmount > BigInt(0) && ( +

+ Donating{" "} + + {formatUnits(parsedAmount, 18)} {reserveLabel} + {" "} + to story #{storylineId} +

+ )} + + + + {error &&

{error}

} + {txHash && txState === "done" && ( +

+ Tx: {txHash.slice(0, 10)}...{txHash.slice(-8)} +

+ )} +
+ ); +} From c4f6667171d16cb348bce827ea2885a56fcdf946 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:05:11 +0000 Subject: [PATCH 2/2] [#26] Handle non-OK indexer response as error Shows informative error when on-chain donation succeeds but indexing fails, rather than showing a false success state. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/DonateWidget.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/DonateWidget.tsx b/src/components/DonateWidget.tsx index adbba660..28475abd 100644 --- a/src/components/DonateWidget.tsx +++ b/src/components/DonateWidget.tsx @@ -70,11 +70,14 @@ export function DonateWidget({ storylineId }: DonateWidgetProps) { // Trigger donation indexer setTxState("indexing"); - await fetch("/api/index/donation", { + const indexRes = await fetch("/api/index/donation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ txHash: hash }), }); + if (!indexRes.ok) { + throw new Error("Donation sent on-chain but indexing failed. It will appear after the next backfill."); + } setTxState("done"); setAmount("");