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..28475abd
--- /dev/null
+++ b/src/components/DonateWidget.tsx
@@ -0,0 +1,158 @@
+"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");
+ 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("");
+ } 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)}
+
+ )}
+
+ );
+}