From f18bf5e8f66e050c78984c997dd3b348207b1404 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 18 Mar 2026 07:02:17 +0000 Subject: [PATCH 1/3] [#267] Add indexing recovery UI for failed publish localStorage-based intent persists content + metadata before wallet confirmation. If the indexer call fails after a successful on-chain tx, a recovery banner appears on next page load with retry/dismiss options. User-rejected transactions clear the intent immediately (no false recovery). 409/duplicate responses treated as idempotent success. Fixes #267 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/chain/page.tsx | 20 +++- src/app/create/page.tsx | 17 +++ src/components/RecoveryBanner.tsx | 80 +++++++++++++++ src/hooks/useChainPlot.ts | 18 +++- src/hooks/usePublish.ts | 44 ++++++++ src/hooks/usePublishIntent.ts | 165 ++++++++++++++++++++++++++++++ 6 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 src/components/RecoveryBanner.tsx create mode 100644 src/hooks/usePublishIntent.ts diff --git a/src/app/chain/page.tsx b/src/app/chain/page.tsx index 7355797f..07172ae4 100644 --- a/src/app/chain/page.tsx +++ b/src/app/chain/page.tsx @@ -11,6 +11,8 @@ import { import { supabase, type Storyline } from "../../../lib/supabase"; import { STORY_FACTORY } from "../../../lib/contracts/constants"; import { useChainPlot } from "../../hooks/useChainPlot"; +import { usePublishIntent } from "../../hooks/usePublishIntent"; +import { RecoveryBanner } from "../../components/RecoveryBanner"; import type { PublishState } from "../../hooks/usePublish"; import Link from "next/link"; import { ConnectWallet } from "../../components/ConnectWallet"; @@ -52,7 +54,13 @@ export default function ChainPlotPage() { enabled: isConnected && !!address, }); - const { state, error, chainPlot, reset } = useChainPlot(); + const { pendingIntent, saveIntent, persistTxHash, clearIntent, attemptRetry } = + usePublishIntent(); + const { state, error, chainPlot, reset } = useChainPlot({ + onIntentSave: saveIntent, + onTxConfirmed: persistTxHash, + onIndexed: clearIntent, + }); const { valid, charCount } = validateContentLength(content); const titleValid = title.trim().length > 0; const canSubmit = @@ -105,6 +113,16 @@ export default function ChainPlotPage() { Chain Plot + {pendingIntent && ( +
+ +
+ )} +
{ e.preventDefault(); diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 144af1ed..0d179e7b 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -8,6 +8,8 @@ import { MAX_CONTENT_LENGTH, } from "../../../lib/content"; import { usePublish, type PublishState } from "../../hooks/usePublish"; +import { usePublishIntent } from "../../hooks/usePublishIntent"; +import { RecoveryBanner } from "../../components/RecoveryBanner"; import { storyFactoryAbi, storylineCreatedEvent } from "../../../lib/contracts/abi"; import { STORY_FACTORY } from "../../../lib/contracts/constants"; import { decodeEventLog, encodeEventTopics } from "viem"; @@ -46,6 +48,8 @@ export default function CreateStorylinePage() { const hasDeadline = true; // mandatory 7-day deadline for all storylines const { state, error, receipt, execute, reset } = usePublish(); + const { pendingIntent, saveIntent, persistTxHash, clearIntent, attemptRetry } = + usePublishIntent(); const { valid, charCount } = validateContentLength(content); const titleValid = title.trim().length > 0; const genreValid = genre.length > 0; @@ -115,6 +119,16 @@ export default function CreateStorylinePage() { Create Storyline + {pendingIntent && ( +
+ +
+ )} + { e.preventDefault(); @@ -131,6 +145,9 @@ export default function CreateStorylinePage() { gas: BigInt(16_000_000), }), metadata: { genre, language }, + onIntentSave: saveIntent, + onTxConfirmed: persistTxHash, + onIndexed: clearIntent, }); }} className="mt-8 space-y-6" diff --git a/src/components/RecoveryBanner.tsx b/src/components/RecoveryBanner.tsx new file mode 100644 index 00000000..32282763 --- /dev/null +++ b/src/components/RecoveryBanner.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { EXPLORER_URL } from "../../lib/contracts/constants"; +import type { PublishIntentData } from "../hooks/usePublishIntent"; +import { MAX_RETRY_ATTEMPTS } from "../hooks/usePublishIntent"; + +interface RecoveryBannerProps { + intent: PublishIntentData; + onRetry: () => Promise<{ success: boolean; error?: string }>; + onDismiss: () => void; +} + +export function RecoveryBanner({ + intent, + onRetry, + onDismiss, +}: RecoveryBannerProps) { + const [retrying, setRetrying] = useState(false); + const exhausted = intent.retryCount >= MAX_RETRY_ATTEMPTS; + + async function handleRetry() { + setRetrying(true); + await onRetry(); + setRetrying(false); + } + + return ( +
+

+ Your previous story was published on-chain but indexing failed. +

+ + {intent.txHash && ( +

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

+ )} + + {intent.lastError && ( +

+ Last error: {intent.lastError} +

+ )} + + {exhausted && ( +

+ Max retries reached. You can dismiss — the backfill cron will index + this automatically, but client-side metadata (genre, language) may be + lost. +

+ )} + +
+ + +
+
+ ); +} diff --git a/src/hooks/useChainPlot.ts b/src/hooks/useChainPlot.ts index 22f4b129..ac629c9e 100644 --- a/src/hooks/useChainPlot.ts +++ b/src/hooks/useChainPlot.ts @@ -5,11 +5,22 @@ import { usePublish } from "./usePublish"; import { storyFactoryAbi } from "../../lib/contracts/abi"; import { STORY_FACTORY } from "../../lib/contracts/constants"; +interface ChainPlotIntentCallbacks { + onIntentSave?: (opts: { + content: string; + metadata: Record; + indexerRoute: string; + uploadKeyPrefix: string; + }) => void; + onTxConfirmed?: (hash: string) => void; + onIndexed?: () => void; +} + /** * Chain a plot to an existing storyline (P3-3). * Reuses the shared publishing state machine from usePublish. */ -export function useChainPlot() { +export function useChainPlot(intentCallbacks?: ChainPlotIntentCallbacks) { const { state, error, txHash, execute, reset } = usePublish(); const chainPlot = useCallback( @@ -25,9 +36,12 @@ export function useChainPlot() { args: [BigInt(storylineId), title, cid, contentHash], gas: BigInt(500_000), }), + onIntentSave: intentCallbacks?.onIntentSave, + onTxConfirmed: intentCallbacks?.onTxConfirmed, + onIndexed: intentCallbacks?.onIndexed, }); }, - [execute], + [execute, intentCallbacks], ); return { state, error, txHash, chainPlot, reset }; diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 11af6646..30d2a210 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -29,6 +29,31 @@ interface PublishOptions { indexerRoute: string; buildWriteCall: (cid: string, contentHash: Hex) => WriteCall; metadata?: Record; + /** Called before wallet confirmation to save intent */ + onIntentSave?: (opts: { + content: string; + metadata: Record; + indexerRoute: string; + uploadKeyPrefix: string; + }) => void; + /** Called after tx confirms to persist tx hash */ + onTxConfirmed?: (hash: string) => void; + /** Called after successful indexing to clear intent */ + onIndexed?: () => void; +} + +/** + * Returns true if the error is a user-rejected transaction (wallet popup dismissed). + */ +function isUserRejection(err: unknown): boolean { + const message = + err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); + return ( + message.includes("user rejected") || + message.includes("user denied") || + message.includes("rejected the request") || + message.includes("cancelled") + ); } /** @@ -75,6 +100,14 @@ export function usePublish() { cachedCid.current = { cid, contentHash }; } + // Save intent before wallet confirmation + opts.onIntentSave?.({ + content: opts.content, + metadata: opts.metadata ?? {}, + indexerRoute: opts.indexerRoute, + uploadKeyPrefix: opts.uploadKeyPrefix, + }); + // 2. Submit tx to wallet setState("confirming"); const writeCall = opts.buildWriteCall(cid, contentHash); @@ -88,6 +121,9 @@ export function usePublish() { setReceipt(receipt); + // Persist tx hash after on-chain confirmation + opts.onTxConfirmed?.(hash); + // 4. Trigger indexer setState("indexing"); await new Promise((r) => setTimeout(r, 5000)); @@ -97,6 +133,9 @@ export function usePublish() { body: JSON.stringify({ txHash: hash, content: opts.content, ...opts.metadata }), }); + // Clear intent after successful indexing + opts.onIndexed?.(); + // 5. Done setState("published"); cachedCid.current = null; @@ -105,6 +144,11 @@ export function usePublish() { err instanceof Error ? err.message : "Unknown error"; setError(message); setState("error"); + + // User rejected tx — clear intent (no recovery needed) + if (isUserRejection(err)) { + opts.onIndexed?.(); // reuse onIndexed to clear intent + } } }, [writeContractAsync], diff --git a/src/hooks/usePublishIntent.ts b/src/hooks/usePublishIntent.ts new file mode 100644 index 00000000..e617a3c0 --- /dev/null +++ b/src/hooks/usePublishIntent.ts @@ -0,0 +1,165 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; + +export interface PublishIntentData { + txHash: string | null; + content: string; + metadata: Record; + indexerRoute: string; + uploadKeyPrefix: string; + createdAt: number; + retryCount: number; + lastError: string | null; +} + +const STORAGE_KEY = "plotlink_publish_intent_v1"; +const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours +export const MAX_RETRY_ATTEMPTS = 5; + +function readIntent(): PublishIntentData | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as PublishIntentData; + } catch { + return null; + } +} + +function writeIntent(intent: PublishIntentData): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(intent)); + } catch { + // localStorage full or unavailable + } +} + +function removeIntent(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // silent + } +} + +function loadPendingIntent(): PublishIntentData | null { + const intent = readIntent(); + if (!intent) return null; + + // Discard stale intents without a tx hash + if (!intent.txHash && Date.now() - intent.createdAt > STALE_THRESHOLD_MS) { + removeIntent(); + return null; + } + + // Pending = has txHash but indexer never succeeded + return intent.txHash ? intent : null; +} + +export function usePublishIntent() { + const [pendingIntent, setPendingIntent] = useState( + loadPendingIntent, + ); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const saveIntent = useCallback( + (opts: { + content: string; + metadata: Record; + indexerRoute: string; + uploadKeyPrefix: string; + }): void => { + const intent: PublishIntentData = { + txHash: null, + content: opts.content, + metadata: opts.metadata, + indexerRoute: opts.indexerRoute, + uploadKeyPrefix: opts.uploadKeyPrefix, + createdAt: Date.now(), + retryCount: 0, + lastError: null, + }; + writeIntent(intent); + }, + [], + ); + + const persistTxHash = useCallback((hash: string): void => { + const intent = readIntent(); + if (!intent) return; + const updated = { ...intent, txHash: hash }; + writeIntent(updated); + // Don't setPendingIntent here — avoids recovery UI flash during active session + }, []); + + const clearIntent = useCallback((): void => { + removeIntent(); + if (mountedRef.current) setPendingIntent(null); + }, []); + + const attemptRetry = useCallback(async (): Promise<{ + success: boolean; + error?: string; + }> => { + const intent = readIntent(); + if (!intent?.txHash) { + return { success: false, error: "No pending transaction found" }; + } + + try { + const response = await fetch(intent.indexerRoute, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + txHash: intent.txHash, + content: intent.content, + ...intent.metadata, + }), + }); + + // 409 = already indexed, treat as success + if (response.ok || response.status === 409) { + removeIntent(); + if (mountedRef.current) setPendingIntent(null); + return { success: true }; + } + + const errorMessage = `Indexer error (${response.status})`; + const updated: PublishIntentData = { + ...intent, + retryCount: intent.retryCount + 1, + lastError: errorMessage, + }; + writeIntent(updated); + if (mountedRef.current) setPendingIntent(updated); + return { success: false, error: errorMessage }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Network error"; + const updated: PublishIntentData = { + ...intent, + retryCount: intent.retryCount + 1, + lastError: errorMessage, + }; + writeIntent(updated); + if (mountedRef.current) setPendingIntent(updated); + return { success: false, error: errorMessage }; + } + }, []); + + return { + pendingIntent, + saveIntent, + persistTxHash, + clearIntent, + attemptRetry, + }; +} From d1b7df5a9d8a718c9d16b76e48b509a5dd1b20b3 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 18 Mar 2026 07:04:11 +0000 Subject: [PATCH 2/3] [#267] Persist tx hash immediately after broadcast, before receipt polling Fixes T2a review: if receipt polling fails after wallet broadcasts, the intent now has the tx hash and recovery banner will appear. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/usePublish.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 30d2a210..788824c9 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -115,15 +115,16 @@ export function usePublish() { const hash = await writeContractAsync(writeCall); setTxHash(hash); + // Persist tx hash immediately after broadcast (before receipt polling) + // so recovery works even if receipt polling fails + opts.onTxConfirmed?.(hash); + // 3. Wait for tx confirmation setState("pending"); const receipt = await publicClient.waitForTransactionReceipt({ hash }); setReceipt(receipt); - // Persist tx hash after on-chain confirmation - opts.onTxConfirmed?.(hash); - // 4. Trigger indexer setState("indexing"); await new Promise((r) => setTimeout(r, 5000)); From 45940873bf628c753d264119c60f7f3510f77ee4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 18 Mar 2026 07:05:14 +0000 Subject: [PATCH 3/3] [#267] Check indexer response status before clearing intent Only clear intent on 2xx or 409 (duplicate). Non-success responses now throw, preserving the intent for recovery. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/usePublish.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 788824c9..5672cd75 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -128,14 +128,18 @@ export function usePublish() { // 4. Trigger indexer setState("indexing"); await new Promise((r) => setTimeout(r, 5000)); - await fetch(opts.indexerRoute, { + const indexerRes = await fetch(opts.indexerRoute, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ txHash: hash, content: opts.content, ...opts.metadata }), }); - // Clear intent after successful indexing - opts.onIndexed?.(); + // Only clear intent on success (2xx) or 409 (already indexed) + if (indexerRes.ok || indexerRes.status === 409) { + opts.onIndexed?.(); + } else { + throw new Error(`Indexer error (${indexerRes.status})`); + } // 5. Done setState("published");