Skip to content

Commit 7fecf8d

Browse files
sktbrdclaude
andcommitted
feat(poidh): optimistic claim render + localStorage persistence until indexer catches up
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2328c58 commit 7fecf8d

2 files changed

Lines changed: 78 additions & 14 deletions

File tree

src/components/bounties/BountyDetailView.tsx

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useCallback, useEffect, useState } from "react";
44
import Link from "next/link";
55
import { useQuery, useQueryClient } from "@tanstack/react-query";
66
import {
@@ -47,7 +47,7 @@ import { POIDH_ABI } from "@/lib/poidh/abi";
4747
import { CHAIN_NAMES, getExplorerUrl, getTxUrl, POIDH_CONTRACTS } from "@/lib/poidh/config";
4848
import { getThirdwebClient } from "@/lib/thirdweb";
4949
import { THIRDWEB_AA_CONFIG, THIRDWEB_WALLETS } from "@/lib/thirdweb-wallets";
50-
import type { PoidhBounty } from "@/types/poidh";
50+
import type { PoidhBounty, PoidhClaim } from "@/types/poidh";
5151

5252
const VIDEO_EXTENSIONS = /\.(mov|mp4|webm|ogg|m4v)(\?.*)?$/i;
5353
const IPFS_GATEWAYS = [
@@ -201,25 +201,87 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
201201

202202
// Refresh bounty state after actions that change on-chain status
203203
const bountyQueryKey = ["poidh-bounty", chainId, bountyId];
204+
205+
// Refresh bounty state after actions that change on-chain status
204206
useEffect(() => {
205-
if (submitForVoteHook.isSuccess) {
206-
queryClient.invalidateQueries({ queryKey: bountyQueryKey });
207-
}
207+
if (submitForVoteHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey });
208208
// eslint-disable-next-line react-hooks/exhaustive-deps
209209
}, [submitForVoteHook.isSuccess]);
210210
useEffect(() => {
211-
if (resolveVoteHook.isSuccess) {
212-
queryClient.invalidateQueries({ queryKey: bountyQueryKey });
213-
}
211+
if (resolveVoteHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey });
214212
// eslint-disable-next-line react-hooks/exhaustive-deps
215213
}, [resolveVoteHook.isSuccess]);
216214
useEffect(() => {
217-
if (cancelHook.isSuccess) {
218-
queryClient.invalidateQueries({ queryKey: bountyQueryKey });
219-
}
215+
if (cancelHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey });
220216
// eslint-disable-next-line react-hooks/exhaustive-deps
221217
}, [cancelHook.isSuccess]);
222218

219+
// Optimistic claim helpers — persist across page refresh until indexer catches up
220+
const pendingClaimKey = `poidh:pending-claim:${chainId}:${bountyId}`;
221+
222+
const injectOptimisticClaim = useCallback(
223+
(pending: { name: string; description: string; url: string; issuer: string; savedAt: number }) => {
224+
queryClient.setQueryData<{ bounty: PoidhBounty }>(["poidh-bounty", chainId, bountyId], (old) => {
225+
if (!old) return old;
226+
const already = old.bounty.claims?.some(
227+
(c) => c.issuer.toLowerCase() === pending.issuer.toLowerCase() && c.name === pending.name && c.id < 2_000_000_000,
228+
);
229+
if (already) return old;
230+
const tmpId = Date.now();
231+
const optimistic: PoidhClaim = {
232+
id: tmpId,
233+
onChainId: tmpId,
234+
bountyId: old.bounty.id,
235+
name: pending.name,
236+
description: pending.description,
237+
url: pending.url || null,
238+
issuer: pending.issuer,
239+
createdAt: Math.floor(pending.savedAt / 1000),
240+
accepted: false,
241+
};
242+
return { bounty: { ...old.bounty, claims: [...(old.bounty.claims ?? []), optimistic], hasClaims: true } };
243+
});
244+
},
245+
[queryClient, chainId, bountyId],
246+
);
247+
248+
// On mount: restore pending claim from localStorage if indexer hasn't caught up yet
249+
useEffect(() => {
250+
try {
251+
const raw = localStorage.getItem(pendingClaimKey);
252+
if (!raw) return;
253+
injectOptimisticClaim(JSON.parse(raw) as { name: string; description: string; url: string; issuer: string; savedAt: number });
254+
} catch { /* ignore parse errors */ }
255+
// eslint-disable-next-line react-hooks/exhaustive-deps
256+
}, []);
257+
258+
// Clear localStorage once the real claim arrives from the API
259+
useEffect(() => {
260+
if (!bounty?.claims) return;
261+
try {
262+
const raw = localStorage.getItem(pendingClaimKey);
263+
if (!raw) return;
264+
const pending = JSON.parse(raw) as { name: string; issuer: string };
265+
const arrived = bounty.claims.some(
266+
(c) => c.issuer.toLowerCase() === pending.issuer.toLowerCase() && c.name === pending.name && c.id < 2_000_000_000,
267+
);
268+
if (arrived) localStorage.removeItem(pendingClaimKey);
269+
} catch { /* ignore */ }
270+
// eslint-disable-next-line react-hooks/exhaustive-deps
271+
}, [bounty?.claims]);
272+
273+
const handleClaimSuccess = useCallback(
274+
({ name, description, url }: { name: string; description: string; url: string }) => {
275+
if (!address) return;
276+
const pending = { name, description, url, issuer: address, savedAt: Date.now() };
277+
try { localStorage.setItem(pendingClaimKey, JSON.stringify(pending)); } catch { /* quota */ }
278+
injectOptimisticClaim(pending);
279+
setTimeout(() => queryClient.invalidateQueries({ queryKey: bountyQueryKey }), 15_000);
280+
},
281+
// eslint-disable-next-line react-hooks/exhaustive-deps
282+
[address, injectOptimisticClaim, queryClient],
283+
);
284+
223285
const deadlineTimestamp = bounty?.deadline ?? null;
224286
const countdown = useCountdown(deadlineTimestamp);
225287

@@ -414,7 +476,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
414476
Be the first to complete this challenge. Film your proof and submit it on-chain.
415477
</p>
416478
</div>
417-
<ClaimBountyModal bounty={bounty}>
479+
<ClaimBountyModal bounty={bounty} onSuccess={handleClaimSuccess}>
418480
<Button size="lg" className="mt-2">
419481
Join
420482
</Button>
@@ -816,7 +878,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
816878
</CardDescription>
817879
</CardHeader>
818880
<CardContent>
819-
<ClaimBountyModal bounty={bounty}>
881+
<ClaimBountyModal bounty={bounty} onSuccess={handleClaimSuccess}>
820882
<Button size="lg" className="w-full">
821883
Join
822884
</Button>

src/components/bounties/ClaimBountyModal.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ import type { PoidhBounty } from "@/types/poidh";
2525
interface ClaimBountyModalProps {
2626
bounty: PoidhBounty;
2727
children: React.ReactNode;
28+
onSuccess?: (claim: { name: string; description: string; url: string }) => void;
2829
}
2930

30-
export function ClaimBountyModal({ bounty, children }: ClaimBountyModalProps) {
31+
export function ClaimBountyModal({ bounty, children, onSuccess }: ClaimBountyModalProps) {
3132
const [open, setOpen] = useState(false);
3233
const [name, setName] = useState("");
3334
const [description, setDescription] = useState("");
@@ -61,6 +62,7 @@ export function ClaimBountyModal({ bounty, children }: ClaimBountyModalProps) {
6162
if (!name.trim() || !description.trim()) return;
6263
try {
6364
await submit(bounty.onChainId, name.trim(), description.trim(), mediaUrl.trim());
65+
onSuccess?.({ name: name.trim(), description: description.trim(), url: mediaUrl.trim() });
6466
} catch {
6567
// error captured in hook
6668
}

0 commit comments

Comments
 (0)