|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useEffect, useState } from "react"; |
| 3 | +import { useCallback, useEffect, useState } from "react"; |
4 | 4 | import Link from "next/link"; |
5 | 5 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
6 | 6 | import { |
@@ -47,7 +47,7 @@ import { POIDH_ABI } from "@/lib/poidh/abi"; |
47 | 47 | import { CHAIN_NAMES, getExplorerUrl, getTxUrl, POIDH_CONTRACTS } from "@/lib/poidh/config"; |
48 | 48 | import { getThirdwebClient } from "@/lib/thirdweb"; |
49 | 49 | 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"; |
51 | 51 |
|
52 | 52 | const VIDEO_EXTENSIONS = /\.(mov|mp4|webm|ogg|m4v)(\?.*)?$/i; |
53 | 53 | const IPFS_GATEWAYS = [ |
@@ -201,25 +201,87 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet |
201 | 201 |
|
202 | 202 | // Refresh bounty state after actions that change on-chain status |
203 | 203 | const bountyQueryKey = ["poidh-bounty", chainId, bountyId]; |
| 204 | + |
| 205 | + // Refresh bounty state after actions that change on-chain status |
204 | 206 | useEffect(() => { |
205 | | - if (submitForVoteHook.isSuccess) { |
206 | | - queryClient.invalidateQueries({ queryKey: bountyQueryKey }); |
207 | | - } |
| 207 | + if (submitForVoteHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey }); |
208 | 208 | // eslint-disable-next-line react-hooks/exhaustive-deps |
209 | 209 | }, [submitForVoteHook.isSuccess]); |
210 | 210 | useEffect(() => { |
211 | | - if (resolveVoteHook.isSuccess) { |
212 | | - queryClient.invalidateQueries({ queryKey: bountyQueryKey }); |
213 | | - } |
| 211 | + if (resolveVoteHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey }); |
214 | 212 | // eslint-disable-next-line react-hooks/exhaustive-deps |
215 | 213 | }, [resolveVoteHook.isSuccess]); |
216 | 214 | useEffect(() => { |
217 | | - if (cancelHook.isSuccess) { |
218 | | - queryClient.invalidateQueries({ queryKey: bountyQueryKey }); |
219 | | - } |
| 215 | + if (cancelHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey }); |
220 | 216 | // eslint-disable-next-line react-hooks/exhaustive-deps |
221 | 217 | }, [cancelHook.isSuccess]); |
222 | 218 |
|
| 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 | + |
223 | 285 | const deadlineTimestamp = bounty?.deadline ?? null; |
224 | 286 | const countdown = useCountdown(deadlineTimestamp); |
225 | 287 |
|
@@ -414,7 +476,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet |
414 | 476 | Be the first to complete this challenge. Film your proof and submit it on-chain. |
415 | 477 | </p> |
416 | 478 | </div> |
417 | | - <ClaimBountyModal bounty={bounty}> |
| 479 | + <ClaimBountyModal bounty={bounty} onSuccess={handleClaimSuccess}> |
418 | 480 | <Button size="lg" className="mt-2"> |
419 | 481 | Join |
420 | 482 | </Button> |
@@ -816,7 +878,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet |
816 | 878 | </CardDescription> |
817 | 879 | </CardHeader> |
818 | 880 | <CardContent> |
819 | | - <ClaimBountyModal bounty={bounty}> |
| 881 | + <ClaimBountyModal bounty={bounty} onSuccess={handleClaimSuccess}> |
820 | 882 | <Button size="lg" className="w-full"> |
821 | 883 | Join |
822 | 884 | </Button> |
|
0 commit comments