Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/app/chain/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -105,6 +113,16 @@ export default function ChainPlotPage() {
Chain Plot
</h1>

{pendingIntent && (
<div className="mt-6">
<RecoveryBanner
intent={pendingIntent}
onRetry={attemptRetry}
onDismiss={clearIntent}
/>
</div>
)}

<form
onSubmit={(e) => {
e.preventDefault();
Expand Down
17 changes: 17 additions & 0 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
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";
Expand Down Expand Up @@ -45,7 +47,9 @@
const [content, setContent] = useState("");
const hasDeadline = true; // mandatory 7-day deadline for all storylines

const { state, error, receipt, execute, reset } = usePublish();

Check warning on line 50 in src/app/create/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'reset' is assigned a value but never used
const { pendingIntent, saveIntent, persistTxHash, clearIntent, attemptRetry } =
usePublishIntent();
const { valid, charCount } = validateContentLength(content);
const titleValid = title.trim().length > 0;
const genreValid = genre.length > 0;
Expand Down Expand Up @@ -115,6 +119,16 @@
Create Storyline
</h1>

{pendingIntent && (
<div className="mt-6">
<RecoveryBanner
intent={pendingIntent}
onRetry={attemptRetry}
onDismiss={clearIntent}
/>
</div>
)}

<form
onSubmit={(e) => {
e.preventDefault();
Expand All @@ -131,6 +145,9 @@
gas: BigInt(16_000_000),
}),
metadata: { genre, language },
onIntentSave: saveIntent,
onTxConfirmed: persistTxHash,
onIndexed: clearIntent,
});
}}
className="mt-8 space-y-6"
Expand Down
80 changes: 80 additions & 0 deletions src/components/RecoveryBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="border-accent/40 bg-surface mb-6 rounded border p-4">
<p className="text-foreground text-sm font-medium">
Your previous story was published on-chain but indexing failed.
</p>

{intent.txHash && (
<p className="text-muted mt-1 text-xs">
Tx:{" "}
<a
href={`${EXPLORER_URL}/tx/${intent.txHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{intent.txHash.slice(0, 10)}...{intent.txHash.slice(-8)}
</a>
</p>
)}

{intent.lastError && (
<p className="text-error mt-1 text-xs">
Last error: {intent.lastError}
</p>
)}

{exhausted && (
<p className="text-muted mt-2 text-xs">
Max retries reached. You can dismiss — the backfill cron will index
this automatically, but client-side metadata (genre, language) may be
lost.
</p>
)}

<div className="mt-3 flex gap-2">
<button
onClick={handleRetry}
disabled={retrying || exhausted}
className="border-accent text-accent hover:bg-accent hover:text-background rounded border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
>
{retrying ? "Retrying..." : "Retry Indexing"}
</button>
<button
onClick={onDismiss}
disabled={retrying}
className="border-border text-muted hover:text-foreground rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
>
Dismiss
</button>
</div>
</div>
);
}
18 changes: 16 additions & 2 deletions src/hooks/useChainPlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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(
Expand All @@ -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 };
Expand Down
51 changes: 50 additions & 1 deletion src/hooks/usePublish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ interface PublishOptions {
indexerRoute: string;
buildWriteCall: (cid: string, contentHash: Hex) => WriteCall;
metadata?: Record<string, string>;
/** Called before wallet confirmation to save intent */
onIntentSave?: (opts: {
content: string;
metadata: Record<string, string>;
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")
);
}

/**
Expand Down Expand Up @@ -75,13 +100,25 @@ 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);

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 });
Expand All @@ -91,12 +128,19 @@ 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 }),
});

// 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");
cachedCid.current = null;
Expand All @@ -105,6 +149,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],
Expand Down
Loading
Loading