diff --git a/src/app/issues/[id]/IssueActions.tsx b/src/app/issues/[id]/IssueActions.tsx index 3b91ae4..8417ecd 100644 --- a/src/app/issues/[id]/IssueActions.tsx +++ b/src/app/issues/[id]/IssueActions.tsx @@ -1,12 +1,31 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/Button"; import { useAuth } from "@/context/AuthContext"; import { useWallet } from "@/context/WalletContext"; -import { apiPost, ApiRequestError } from "@/lib/api"; -import type { Bounty } from "@/types"; +import { apiPost, apiRequest, ApiRequestError } from "@/lib/api"; +import { useSmartPolling } from "@/hooks/useSmartPolling"; +import type { Bounty, BountyStatus } from "@/types"; + +/** + * Claim-race detection (Issue #46): + * When a claim attempt fails because another user claimed first, the backend + * returns a 409 Conflict or a message containing "already claimed". We detect + * this specifically and show a distinct UI rather than a generic error. + * + * If the backend doesn't distinguish this case yet, this code treats any 409 + * or message matching /already.?claimed|claim.*race/i as a race loss. + * Backend contract note: ideally return { code: "CLAIM_RACE_LOST", status: "claimed", claimedBy: "..." } + */ +function isClaimRaceLoss(err: unknown): boolean { + if (err instanceof ApiRequestError) { + if (err.status === 409) return true; + if (/already.?claimed|claim.*race|already.*taken/i.test(err.message)) return true; + } + return false; +} export function IssueActions({ bounty }: { bounty: Bounty }) { const router = useRouter(); @@ -15,10 +34,40 @@ export function IssueActions({ bounty }: { bounty: Bounty }) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); + const [raceLost, setRaceLost] = useState(false); + const [currentStatus, setCurrentStatus] = useState(bounty.status); + const statusRef = useRef(bounty.status); + + // Smart polling: re-fetch bounty status every 5s when visible, backing off + // if unchanged. This keeps the claim button state accurate without manual refresh. + const fetchStatus = useCallback(async () => { + try { + const updated = await apiRequest<{ status: BountyStatus; claimedBy?: string | null }>( + `/bounties/${bounty.id}/status`, + ); + if (updated.status !== statusRef.current) { + statusRef.current = updated.status; + setCurrentStatus(updated.status); + signalChange(); + } else { + signalNoChange(); + } + } catch { + // Silently ignore polling errors — don't disrupt the UI + } + }, [bounty.id]); + + const { signalChange, signalNoChange } = useSmartPolling(fetchStatus, { + interval: 5000, + maxBackoff: 12, + // Only poll when viewing an actionable bounty state + enabled: ["open", "funded", "claimed"].includes(currentStatus), + }); async function withWallet(action: (walletAddress: string) => Promise) { setError(null); setNotice(null); + setRaceLost(false); setPending(true); try { const walletAddress = address ?? (await connect()); @@ -45,6 +94,7 @@ export function IssueActions({ bounty }: { bounty: Bounty }) { async function handleClaim() { setError(null); setNotice(null); + setRaceLost(false); if (!user) { router.push("/connect"); return; @@ -55,7 +105,13 @@ export function IssueActions({ bounty }: { bounty: Bounty }) { setNotice("You've claimed this issue. Open a pull request to get started."); router.refresh(); } catch (err) { - setError(err instanceof ApiRequestError ? err.message : "Something went wrong."); + if (isClaimRaceLoss(err)) { + setRaceLost(true); + // Force an immediate status refresh to show the new claimant + await fetchStatus(); + } else { + setError(err instanceof ApiRequestError ? err.message : "Something went wrong."); + } } finally { setPending(false); } @@ -76,42 +132,53 @@ export function IssueActions({ bounty }: { bounty: Bounty }) { } } + // Use polled status for rendering decisions, fall back to prop + const displayStatus = currentStatus; + return (
- {bounty.status === "open" && ( + {displayStatus === "open" && ( )} - {bounty.status === "funded" && ( + {displayStatus === "funded" && ( )} - {(bounty.status === "funded" || bounty.status === "claimed") && ( + {(displayStatus === "funded" || displayStatus === "claimed") && ( )} - {["in_review", "merged", "paid", "refunded", "expired"].includes( - bounty.status, - ) && ( + {["in_review", "merged", "paid", "refunded", "expired"].includes(displayStatus) && ( )}
+ + {/* Claim-race-specific messaging — distinct from generic errors */} + {raceLost && ( +
+ Someone else claimed this bounty first. The status has been updated. + You can look for other open bounties to claim. +
+ )} + {notice &&

{notice}

} - {error &&

{error}

} + {error && !raceLost &&

{error}

}

Funding and claiming write to the live mergefi-backend API. Merge detection and payout release happen automatically via GitHub - webhooks once a linked pull request is merged. + webhooks once a linked pull request is merged. Status auto-refreshes + while this page is active.

); diff --git a/src/hooks/useSmartPolling.ts b/src/hooks/useSmartPolling.ts new file mode 100644 index 0000000..1fef48e --- /dev/null +++ b/src/hooks/useSmartPolling.ts @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; + +interface SmartPollingOptions { + /** Polling interval in ms when tab is visible and data is changing */ + interval: number; + /** Maximum backoff multiplier when data hasn't changed (default: 8x interval) */ + maxBackoff?: number; + /** Whether polling is enabled */ + enabled?: boolean; +} + +/** + * Tab-aware smart polling hook with exponential backoff. + * - Pauses when tab is backgrounded (visibility API) + * - Backs off exponentially when consecutive polls return unchanged data + * - Resets to base interval when data changes or tab regains focus + * + * This is the interim solution until backend websocket events are available + * (per roadmap: "Real-time bounty/escrow status via websockets or polling + * once the backend emits webhook-driven events" is future work). + */ +export function useSmartPolling( + fetcher: () => Promise, + options: SmartPollingOptions, +) { + const { interval, maxBackoff = 8, enabled = true } = options; + const timerRef = useRef | null>(null); + const backoffRef = useRef(1); + const isVisibleRef = useRef(true); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const scheduleNext = useCallback(() => { + if (!enabled || !isVisibleRef.current) return; + const delay = Math.min(interval * backoffRef.current, interval * maxBackoff); + timerRef.current = setTimeout(async () => { + try { + await fetcher(); + } catch { + // On error, don't increase backoff — retry at current rate + } + scheduleNext(); + }, delay); + }, [fetcher, interval, maxBackoff, enabled]); + + /** Signal that data has changed — resets backoff to 1x */ + const signalChange = useCallback(() => { + backoffRef.current = 1; + clearTimer(); + scheduleNext(); + }, [clearTimer, scheduleNext]); + + /** Signal that data was unchanged — increases backoff */ + const signalNoChange = useCallback(() => { + backoffRef.current = Math.min(backoffRef.current * 2, maxBackoff); + }, [maxBackoff]); + + useEffect(() => { + if (!enabled) { + clearTimer(); + return; + } + + const handleVisibility = () => { + isVisibleRef.current = document.visibilityState === "visible"; + if (isVisibleRef.current) { + // Tab regained focus — reset backoff and restart polling + backoffRef.current = 1; + clearTimer(); + scheduleNext(); + } else { + // Tab backgrounded — pause polling + clearTimer(); + } + }; + + document.addEventListener("visibilitychange", handleVisibility); + + // Start polling if visible + if (document.visibilityState === "visible") { + scheduleNext(); + } + + return () => { + document.removeEventListener("visibilitychange", handleVisibility); + clearTimer(); + }; + }, [enabled, scheduleNext, clearTimer]); + + return { signalChange, signalNoChange }; +}