Skip to content

Commit 3b60541

Browse files
authored
fix(poidh): refresh bounty state after submitForVote/resolve/cancel (#91)
fix(poidh): refresh bounty state after submitForVote/resolve/cancel
2 parents d3b67c8 + bdfbc5b commit 3b60541

4 files changed

Lines changed: 165 additions & 8 deletions

File tree

src/components/bounties/BountyDetailView.tsx

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useCallback, useEffect, useState } from "react";
44
import Link from "next/link";
5-
import { useQuery } from "@tanstack/react-query";
5+
import { useQuery, useQueryClient } from "@tanstack/react-query";
66
import {
77
AlertCircle,
88
ArrowLeft,
@@ -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 = [
@@ -183,6 +183,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
183183
});
184184

185185
const bounty = data?.bounty;
186+
const queryClient = useQueryClient();
186187

187188
const { ethPrice } = useEthPrice();
188189
const [joinAmount, setJoinAmount] = useState("0.001");
@@ -198,6 +199,89 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
198199
const resolveVoteHook = usePoidhResolveVote(chainId);
199200
const resetVotingHook = usePoidhResetVotingPeriod(chainId);
200201

202+
// Refresh bounty state after actions that change on-chain status
203+
const bountyQueryKey = ["poidh-bounty", chainId, bountyId];
204+
205+
// Refresh bounty state after actions that change on-chain status
206+
useEffect(() => {
207+
if (submitForVoteHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey });
208+
// eslint-disable-next-line react-hooks/exhaustive-deps
209+
}, [submitForVoteHook.isSuccess]);
210+
useEffect(() => {
211+
if (resolveVoteHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey });
212+
// eslint-disable-next-line react-hooks/exhaustive-deps
213+
}, [resolveVoteHook.isSuccess]);
214+
useEffect(() => {
215+
if (cancelHook.isSuccess) queryClient.invalidateQueries({ queryKey: bountyQueryKey });
216+
// eslint-disable-next-line react-hooks/exhaustive-deps
217+
}, [cancelHook.isSuccess]);
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+
201285
const deadlineTimestamp = bounty?.deadline ?? null;
202286
const countdown = useCountdown(deadlineTimestamp);
203287

@@ -392,7 +476,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
392476
Be the first to complete this challenge. Film your proof and submit it on-chain.
393477
</p>
394478
</div>
395-
<ClaimBountyModal bounty={bounty}>
479+
<ClaimBountyModal bounty={bounty} onSuccess={handleClaimSuccess}>
396480
<Button size="lg" className="mt-2">
397481
Join
398482
</Button>
@@ -794,7 +878,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
794878
</CardDescription>
795879
</CardHeader>
796880
<CardContent>
797-
<ClaimBountyModal bounty={bounty}>
881+
<ClaimBountyModal bounty={bounty} onSuccess={handleClaimSuccess}>
798882
<Button size="lg" className="w-full">
799883
Join
800884
</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
}

src/hooks/usePoidhContract.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,44 @@ function buildPoidhReturn(state: PoidhWriteState) {
6969
};
7070
}
7171

72+
const POIDH_ERROR_MESSAGES: Record<string, string> = {
73+
AlreadyVoted: "You have already voted on this claim.",
74+
ClaimNotFound: "Claim not found on-chain. Make sure you're on the right network.",
75+
BountyNotFound: "Bounty not found on-chain.",
76+
VotingOngoing: "Voting is still in progress — wait for the deadline to resolve.",
77+
VotingEnded: "The voting period has ended.",
78+
NoVotingPeriodSet: "No voting period is active for this bounty.",
79+
BountyClaimed: "This bounty has already been claimed.",
80+
BountyClosed: "This bounty is closed.",
81+
WrongCaller: "You are not allowed to perform this action.",
82+
IssuerCannotClaim: "The bounty creator cannot submit a claim.",
83+
IssuerCannotWithdraw: "The bounty creator cannot withdraw as a contributor.",
84+
NotActiveParticipant: "You are not a participant in this bounty.",
85+
NotOpenBounty: "This action is only available for open bounties.",
86+
NotSoloBounty: "This action is only available for solo bounties.",
87+
ClaimAlreadyAccepted: "This claim has already been accepted.",
88+
NothingToWithdraw: "Nothing to withdraw.",
89+
MaxParticipantsReached: "This bounty has reached its maximum number of participants.",
90+
NotCancelledOpenBounty: "This bounty has not been cancelled.",
91+
VoteWouldPass: "Cannot reset — the vote would pass. Resolve it instead.",
92+
MinimumBountyNotMet: "Amount is below the minimum required to create a bounty.",
93+
MinimumContributionNotMet: "Amount is below the minimum required to contribute.",
94+
NoEther: "No ETH sent.",
95+
ContractsCannotCreateBounties: "Smart contracts cannot create bounties directly.",
96+
TransferFailed: "ETH transfer failed.",
97+
InsufficientBalance: "Insufficient balance.",
98+
};
99+
100+
function decodePoidhError(err: unknown): Error {
101+
if (!(err instanceof Error)) return new Error(String(err));
102+
const msg = err.message;
103+
for (const [name, friendly] of Object.entries(POIDH_ERROR_MESSAGES)) {
104+
if (msg.includes(name)) return new Error(friendly);
105+
}
106+
// Fallback: trim the noisy viem stack to the first line
107+
return new Error(msg.split("\n")[0]);
108+
}
109+
72110
async function assertPoidhReady(ctx: ReturnType<typeof usePoidhContext>, chainId: number) {
73111
if (!ctx.isConnected) throw new Error("Connect your wallet first");
74112
if (!ctx.contractAddress) throw new Error(`Unsupported chain: ${chainId}`);
@@ -113,7 +151,7 @@ async function sendAndConfirm(
113151
state.setIsSuccess(true);
114152
return txHash;
115153
} catch (err) {
116-
state.setError(err instanceof Error ? err : new Error(String(err)));
154+
state.setError(decodePoidhError(err));
117155
throw err;
118156
} finally {
119157
state.setIsPending(false);

src/lib/poidh/abi.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,5 +182,38 @@ export const POIDH_ABI = [
182182
"outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }],
183183
"stateMutability": "view",
184184
"type": "function"
185-
}
185+
},
186+
// ── Custom errors ────────────────────────────────────────────────────────
187+
{ "inputs": [], "name": "NoEther", "type": "error" },
188+
{ "inputs": [], "name": "MinimumBountyNotMet", "type": "error" },
189+
{ "inputs": [], "name": "MinimumContributionNotMet", "type": "error" },
190+
{ "inputs": [], "name": "BountyNotFound", "type": "error" },
191+
{ "inputs": [], "name": "ClaimNotFound", "type": "error" },
192+
{ "inputs": [], "name": "VotingOngoing", "type": "error" },
193+
{ "inputs": [], "name": "VotingEnded", "type": "error" },
194+
{ "inputs": [], "name": "NoVotingPeriodSet", "type": "error" },
195+
{ "inputs": [], "name": "BountyClaimed", "type": "error" },
196+
{ "inputs": [], "name": "BountyClosed", "type": "error" },
197+
{ "inputs": [], "name": "NotOpenBounty", "type": "error" },
198+
{ "inputs": [], "name": "NotSoloBounty", "type": "error" },
199+
{ "inputs": [], "name": "WrongCaller", "type": "error" },
200+
{ "inputs": [], "name": "IssuerCannotClaim", "type": "error" },
201+
{ "inputs": [], "name": "IssuerCannotWithdraw", "type": "error" },
202+
{ "inputs": [], "name": "NotActiveParticipant", "type": "error" },
203+
{ "inputs": [], "name": "AlreadyVoted", "type": "error" },
204+
{ "inputs": [], "name": "ClaimAlreadyAccepted", "type": "error" },
205+
{ "inputs": [], "name": "NothingToWithdraw", "type": "error" },
206+
{ "inputs": [], "name": "TransferFailed", "type": "error" },
207+
{ "inputs": [], "name": "InsufficientBalance", "type": "error" },
208+
{ "inputs": [], "name": "MaxParticipantsReached", "type": "error" },
209+
{ "inputs": [], "name": "NotCancelledOpenBounty", "type": "error" },
210+
{ "inputs": [], "name": "VoteWouldPass", "type": "error" },
211+
{ "inputs": [], "name": "InvalidStartClaimIndex", "type": "error" },
212+
{ "inputs": [], "name": "ContractsCannotCreateBounties", "type": "error" },
213+
{ "inputs": [], "name": "InvalidTreasury", "type": "error" },
214+
{ "inputs": [], "name": "InvalidPoidhNft", "type": "error" },
215+
{ "inputs": [], "name": "InvalidWithdrawTo", "type": "error" },
216+
{ "inputs": [], "name": "DirectEtherNotAccepted", "type": "error" },
217+
{ "inputs": [], "name": "InvalidMinBountyAmount", "type": "error" },
218+
{ "inputs": [], "name": "InvalidMinContribution", "type": "error" }
186219
] as const;

0 commit comments

Comments
 (0)