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)} +

+ )} +
+ ); +}