diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx
index 90d7de3..b10ccc2 100644
--- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx
+++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx
@@ -25,6 +25,7 @@ import {
import { BountyFieldsFragment } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
+import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";
import { authClient } from "@/lib/auth-client";
import type { CancellationRecord } from "@/types/escrow";
import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog";
@@ -48,6 +49,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
} = useCancelBountyDialog(bounty.id, onCancelled);
const canAct = bounty.status === "OPEN";
+ const isFcfs = bounty.type === "FIXED_PRICE";
const isCreator = session?.user?.id === bounty.createdBy;
const canCancel =
isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS");
@@ -104,7 +106,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
Status
-
+
Type
@@ -115,17 +117,25 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
{/* CTA */}
-
+ {isFcfs ? (
+
+ ) : (
+
+ )}
{!canAct && (
@@ -218,7 +228,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
placeholder="e.g., Requirements changed, budget reallocation, issue resolved externally..."
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
- className="min-h-[80px] resize-none"
+ className="min-h-20 resize-none"
disabled={isCancelling}
/>
@@ -264,6 +274,7 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
} = useCancelBountyDialog(bounty.id, onCancelled);
const canAct = bounty.status === "OPEN";
+ const isFcfs = bounty.type === "FIXED_PRICE";
const isCreator = session?.user?.id === bounty.createdBy;
const canCancel =
isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS");
@@ -284,29 +295,37 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
return (
-
-
- {canCancel && (
+ {isFcfs ? (
+
+ ) : (
+
- )}
-
+ {canCancel && (
+
+ )}
+
+ )}
{/* Mobile Cancel Dialog */}
@@ -330,7 +349,7 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
placeholder="Reason for cancellation..."
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
- className="min-h-[80px]"
+ className="min-h-20"
disabled={isCancelling}
/>
diff --git a/components/bounty/bounty-card.tsx b/components/bounty/bounty-card.tsx
index bf0b590..c00230c 100644
--- a/components/bounty/bounty-card.tsx
+++ b/components/bounty/bounty-card.tsx
@@ -65,6 +65,11 @@ const statusConfig: Record<
label: "Under Review",
dotColor: "bg-amber-500",
},
+ in_review: {
+ variant: "secondary",
+ label: "In Review",
+ dotColor: "bg-amber-500",
+ },
disputed: {
variant: "destructive",
label: "Disputed",
@@ -77,7 +82,13 @@ export function BountyCard({
onClick,
variant = "grid",
}: BountyCardProps) {
- const status = statusConfig[bounty.status];
+ const normalizedStatus = bounty.status
+ .toUpperCase()
+ .replace(/-/g, "_") as string;
+ const status =
+ statusConfig[normalizedStatus.toLowerCase()] ?? statusConfig.open;
+ const isFcfsClaimed =
+ bounty.type === "FIXED_PRICE" && normalizedStatus === "IN_PROGRESS";
const timeLeft = bounty.updatedAt
? formatDistanceToNow(new Date(bounty.updatedAt), { addSuffix: true })
: "N/A";
@@ -112,7 +123,7 @@ export function BountyCard({
- {status.label}
+ {isFcfsClaimed ? "Claimed" : status.label}
diff --git a/components/bounty/fcfs-approval-panel.tsx b/components/bounty/fcfs-approval-panel.tsx
new file mode 100644
index 0000000..df066bc
--- /dev/null
+++ b/components/bounty/fcfs-approval-panel.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { authClient } from "@/lib/auth-client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useApproveFcfs } from "@/hooks/use-claim-bounty";
+
+type FcfsApprovalBounty = {
+ id: string;
+ type: string;
+ status: string;
+ createdBy: string;
+ submissions?: Array<{
+ id: string;
+ githubPullRequestUrl?: string | null;
+ submittedBy?: string;
+ submittedByUser?: { name?: string | null } | null;
+ }> | null;
+};
+
+export function FcfsApprovalPanel({ bounty }: { bounty: FcfsApprovalBounty }) {
+ const { data: session } = authClient.useSession();
+ const approveMutation = useApproveFcfs();
+ const [points, setPoints] = useState(10);
+
+ const currentUserId = (session?.user as { id?: string } | undefined)?.id;
+ const walletAddress =
+ (session?.user as { walletAddress?: string; address?: string } | undefined)
+ ?.walletAddress ||
+ (session?.user as { walletAddress?: string; address?: string } | undefined)
+ ?.address ||
+ null;
+
+ const isCreator = Boolean(
+ currentUserId && currentUserId === bounty.createdBy,
+ );
+ const isFcfs = bounty.type === "FIXED_PRICE";
+ const isReviewState =
+ bounty.status === "IN_REVIEW" || bounty.status === "UNDER_REVIEW";
+
+ if (!isFcfs || !isCreator || !isReviewState) return null;
+
+ const targetSubmission = bounty.submissions?.[0];
+
+ const handleApprove = async () => {
+ if (!walletAddress) {
+ toast.error("Connect your wallet to approve this FCFS bounty.");
+ return;
+ }
+ if (!Number.isFinite(points) || points < 0) {
+ toast.error("Points must be a valid non-negative number.");
+ return;
+ }
+ try {
+ await approveMutation.mutateAsync({
+ bountyId: bounty.id,
+ creatorAddress: walletAddress,
+ points,
+ });
+ toast.success("Approved and released payment.");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Approval failed.");
+ }
+ };
+
+ return (
+
+
+ FCFS Approval & Release
+
+
+ {targetSubmission ? (
+
+
+ Contributor:{" "}
+ {targetSubmission.submittedByUser?.name ||
+ targetSubmission.submittedBy}
+
+ {targetSubmission.githubPullRequestUrl &&
+ targetSubmission.githubPullRequestUrl.startsWith("https://") ? (
+
+ {targetSubmission.githubPullRequestUrl}
+
+ ) : targetSubmission.githubPullRequestUrl ? (
+
+ {targetSubmission.githubPullRequestUrl}
+
+ ) : null}
+
+ ) : (
+
+ No submission metadata is available yet. You can still approve using
+ on-chain state.
+
+ )}
+
+
+
+ setPoints(Number(e.target.value))}
+ />
+
+
+ {!walletAddress && (
+
+ Connect your wallet to approve this bounty.
+
+ )}
+
+
+
+ );
+}
diff --git a/components/bounty/fcfs-claim-button.tsx b/components/bounty/fcfs-claim-button.tsx
new file mode 100644
index 0000000..ba7e223
--- /dev/null
+++ b/components/bounty/fcfs-claim-button.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { AlertTriangle, Clock3, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { authClient } from "@/lib/auth-client";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ FcfsError,
+ useClaimBounty,
+ useUnclaimBounty,
+} from "@/hooks/use-claim-bounty";
+
+type FcfsBounty = {
+ id: string;
+ type: string;
+ status: string;
+ createdBy: string;
+ updatedAt?: string | null;
+ claimsLastMilestoneAt?: string | null;
+ claimsLastResponseAt?: string | null;
+ claimedBy?: string | null;
+ claimedByUser?: { name?: string | null } | null;
+ submissions?: Array<{
+ submittedBy?: string;
+ submittedByUser?: { name?: string | null } | null;
+ }> | null;
+};
+
+function getClaimOwner(bounty: FcfsBounty): {
+ address: string | null;
+ label: string | null;
+} {
+ const address =
+ bounty.claimedBy || bounty.submissions?.[0]?.submittedBy || null;
+ const label =
+ bounty.claimedByUser?.name ||
+ bounty.submissions?.[0]?.submittedByUser?.name ||
+ address;
+ return { address, label: label ? `@${label}` : null };
+}
+
+function formatRemaining(ms: number) {
+ if (ms <= 0) return "Expired";
+ const totalHours = Math.floor(ms / (1000 * 60 * 60));
+ const days = Math.floor(totalHours / 24);
+ const hours = totalHours % 24;
+ return `${days}d ${hours}h`;
+}
+
+export function FcfsClaimButton({ bounty }: { bounty: FcfsBounty }) {
+ const { data: session } = authClient.useSession();
+ const [unclaimOpen, setUnclaimOpen] = useState(false);
+ const [justification, setJustification] = useState("");
+ const [now, setNow] = useState
(() => Date.now());
+ const claimMutation = useClaimBounty();
+ const unclaimMutation = useUnclaimBounty();
+
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 60_000);
+ return () => window.clearInterval(id);
+ }, []);
+
+ const currentUserId = (session?.user as { id?: string } | undefined)?.id;
+ const walletAddress =
+ (session?.user as { walletAddress?: string; address?: string } | undefined)
+ ?.walletAddress ||
+ (session?.user as { walletAddress?: string; address?: string } | undefined)
+ ?.address ||
+ null;
+
+ const isFcfs = bounty.type === "FIXED_PRICE";
+ const isOpen = bounty.status === "OPEN";
+ const isClaimed = bounty.status === "IN_PROGRESS";
+ const isCreator = Boolean(
+ currentUserId && currentUserId === bounty.createdBy,
+ );
+ const owner = getClaimOwner(bounty);
+ const isOwner = Boolean(owner.address && owner.address === walletAddress);
+
+ const milestoneBase = bounty.claimsLastMilestoneAt || bounty.updatedAt;
+ const responseBase = bounty.claimsLastResponseAt || bounty.updatedAt;
+
+ const milestoneMsLeft = useMemo(() => {
+ if (!milestoneBase) return null;
+ return new Date(milestoneBase).getTime() + 7 * 24 * 60 * 60 * 1000 - now;
+ }, [milestoneBase, now]);
+ const responseMsLeft = useMemo(() => {
+ if (!responseBase) return null;
+ return new Date(responseBase).getTime() + 3 * 24 * 60 * 60 * 1000 - now;
+ }, [responseBase, now]);
+
+ if (!isFcfs) return null;
+
+ const handleClaim = async () => {
+ if (!walletAddress) {
+ toast.error("Connect your wallet to claim this bounty.");
+ return;
+ }
+ try {
+ await claimMutation.mutateAsync({
+ bountyId: bounty.id,
+ contributorAddress: walletAddress,
+ });
+ toast.success("Bounty claimed successfully.");
+ } catch (error) {
+ if (error instanceof FcfsError) {
+ toast.error(error.message);
+ return;
+ }
+ toast.error(error instanceof Error ? error.message : "Claim failed.");
+ }
+ };
+
+ const handleUnclaim = async () => {
+ if (!walletAddress) return;
+ try {
+ await unclaimMutation.mutateAsync({
+ bountyId: bounty.id,
+ creatorAddress: walletAddress,
+ justification: justification.trim(),
+ });
+ toast.success("Bounty was unclaimed.");
+ setUnclaimOpen(false);
+ setJustification("");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Unclaim failed.");
+ }
+ };
+
+ return (
+
+ {isOpen && !isCreator && !walletAddress && (
+
+ Connect your wallet to claim this bounty.
+
+ )}
+
+ {isOpen && !isCreator && walletAddress && (
+
+ )}
+
+ {isClaimed && (
+
+
+ {isOwner
+ ? "This FCFS bounty is currently claimed by your wallet."
+ : `Already Claimed${owner.label ? ` by ${owner.label}` : ""}`}
+
+
+
+ {milestoneMsLeft != null && (
+
+
+ Auto-release in {formatRemaining(milestoneMsLeft)} (7d no
+ milestone)
+
+ )}
+ {responseMsLeft != null && (
+
+
+ Auto-release in {formatRemaining(responseMsLeft)} (3d no
+ response)
+
+ )}
+
+
+ {isOwner &&
+ ((milestoneMsLeft != null &&
+ milestoneMsLeft > 0 &&
+ milestoneMsLeft < 24 * 60 * 60 * 1000) ||
+ (responseMsLeft != null &&
+ responseMsLeft > 0 &&
+ responseMsLeft < 24 * 60 * 60 * 1000)) && (
+
+
+ Your claim is close to auto-release. Post progress to avoid
+ abandonment.
+
+ )}
+
+ {isCreator && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/hooks/use-claim-bounty.ts b/hooks/use-claim-bounty.ts
new file mode 100644
index 0000000..6ebf365
--- /dev/null
+++ b/hooks/use-claim-bounty.ts
@@ -0,0 +1,281 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { BountiesQuery, BountyQuery } from "@/lib/graphql/generated";
+import { bountyKeys } from "@/lib/query/query-keys";
+
+type FcfsContractClient = {
+ getCredits: (address: string) => Promise;
+ claimBounty: (params: {
+ contributor: string;
+ bountyId: bigint;
+ }) => Promise<{ txHash: string }>;
+ approveFcfs: (params: {
+ creator: string;
+ bountyId: bigint;
+ points: number;
+ }) => Promise<{ txHash: string }>;
+ unclaimBounty?: (params: {
+ creator: string;
+ bountyId: bigint;
+ justification: string;
+ }) => Promise<{ txHash: string }>;
+};
+
+type ClaimInput = {
+ bountyId: string;
+ contributorAddress: string;
+};
+
+type ApproveInput = {
+ bountyId: string;
+ creatorAddress: string;
+ points: number;
+};
+
+type UnclaimInput = {
+ bountyId: string;
+ creatorAddress: string;
+ justification: string;
+};
+
+type StructuredErrorCode =
+ | "insufficient_credits"
+ | "already_claimed"
+ | "missing_contract_bindings"
+ | "tx_failed";
+
+export class FcfsError extends Error {
+ code: StructuredErrorCode;
+
+ constructor(code: StructuredErrorCode, message: string) {
+ super(message);
+ this.code = code;
+ }
+}
+
+/**
+ * Safely convert a bounty ID to a bigint.
+ * If the ID is a numeric string it is converted directly;
+ * otherwise it is treated as a hex-encoded UUID (dashes stripped).
+ */
+function toBountyIdBigInt(id: string): bigint {
+ if (/^\d+$/.test(id)) return BigInt(id);
+ const hex = id.replace(/-/g, "");
+ if (/^[0-9a-f]+$/i.test(hex)) return BigInt(`0x${hex}`);
+ throw new FcfsError(
+ "tx_failed",
+ `Invalid bounty ID format: "${id}" is neither numeric nor a valid UUID.`,
+ );
+}
+
+function resolveContractClient(): FcfsContractClient {
+ const maybeClient = (globalThis as { __fcfsContracts?: FcfsContractClient })
+ .__fcfsContracts;
+ if (!maybeClient) {
+ throw new FcfsError(
+ "missing_contract_bindings",
+ "FCFS contract bindings are unavailable. Make sure #139 bindings are loaded.",
+ );
+ }
+ return maybeClient;
+}
+
+function applyDetailOptimisticStatus(
+ previous: BountyQuery | undefined,
+ nextStatus: string,
+ extra?: Record,
+) {
+ if (!previous?.bounty) return previous;
+ return {
+ ...previous,
+ bounty: {
+ ...previous.bounty,
+ status: nextStatus,
+ updatedAt: new Date().toISOString(),
+ ...extra,
+ },
+ };
+}
+
+function applyListOptimisticStatus(old: BountiesQuery | undefined, id: string) {
+ if (!old?.bounties?.bounties) return old;
+ return {
+ ...old,
+ bounties: {
+ ...old.bounties,
+ bounties: old.bounties.bounties.map((bounty) =>
+ bounty.id === id
+ ? {
+ ...bounty,
+ status: "IN_PROGRESS",
+ updatedAt: new Date().toISOString(),
+ }
+ : bounty,
+ ),
+ },
+ };
+}
+
+export function useClaimBounty() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ bountyId, contributorAddress }: ClaimInput) => {
+ const client = resolveContractClient();
+ const credits = await client.getCredits(contributorAddress);
+ if (credits < 1) {
+ throw new FcfsError(
+ "insufficient_credits",
+ "You need at least 1 Spark Credit to claim this bounty.",
+ );
+ }
+ try {
+ return await client.claimBounty({
+ contributor: contributorAddress,
+ bountyId: toBountyIdBigInt(bountyId),
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to claim bounty.";
+ if (/already/i.test(message)) {
+ throw new FcfsError("already_claimed", message);
+ }
+ throw new FcfsError("tx_failed", message);
+ }
+ },
+ onMutate: async ({ bountyId, contributorAddress }) => {
+ await queryClient.cancelQueries({
+ queryKey: bountyKeys.detail(bountyId),
+ });
+ await queryClient.cancelQueries({
+ queryKey: bountyKeys.lists(),
+ });
+
+ const previousDetail = queryClient.getQueryData(
+ bountyKeys.detail(bountyId),
+ );
+
+ const previousLists = queryClient.getQueriesData({
+ queryKey: bountyKeys.lists(),
+ });
+
+ queryClient.setQueryData(
+ bountyKeys.detail(bountyId),
+ applyDetailOptimisticStatus(previousDetail, "IN_PROGRESS", {
+ claimedBy: contributorAddress,
+ }),
+ );
+
+ queryClient.setQueriesData(
+ { queryKey: bountyKeys.lists() },
+ (old) => applyListOptimisticStatus(old, bountyId),
+ );
+
+ return { previousDetail, previousLists, bountyId };
+ },
+ onError: (_error, _variables, context) => {
+ if (context?.previousDetail) {
+ queryClient.setQueryData(
+ bountyKeys.detail(context.bountyId),
+ context.previousDetail,
+ );
+ }
+ if (context?.previousLists) {
+ for (const [key, data] of context.previousLists) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ },
+ onSettled: (_result, _error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: bountyKeys.detail(variables.bountyId),
+ });
+ queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
+ },
+ });
+}
+
+export function useApproveFcfs() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ bountyId, creatorAddress, points }: ApproveInput) => {
+ const client = resolveContractClient();
+ return client.approveFcfs({
+ creator: creatorAddress,
+ bountyId: toBountyIdBigInt(bountyId),
+ points,
+ });
+ },
+ onMutate: async ({ bountyId }) => {
+ await queryClient.cancelQueries({
+ queryKey: bountyKeys.detail(bountyId),
+ });
+ await queryClient.cancelQueries({
+ queryKey: bountyKeys.lists(),
+ });
+
+ const previousDetail = queryClient.getQueryData(
+ bountyKeys.detail(bountyId),
+ );
+ const previousLists = queryClient.getQueriesData({
+ queryKey: bountyKeys.lists(),
+ });
+
+ queryClient.setQueryData(
+ bountyKeys.detail(bountyId),
+ applyDetailOptimisticStatus(previousDetail, "COMPLETED"),
+ );
+ return { previousDetail, previousLists, bountyId };
+ },
+ onError: (_error, _variables, context) => {
+ if (context?.previousDetail) {
+ queryClient.setQueryData(
+ bountyKeys.detail(context.bountyId),
+ context.previousDetail,
+ );
+ }
+ if (context?.previousLists) {
+ for (const [key, data] of context.previousLists) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ },
+ onSettled: (_result, _error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: bountyKeys.detail(variables.bountyId),
+ });
+ queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
+ },
+ });
+}
+
+export function useUnclaimBounty() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({
+ bountyId,
+ creatorAddress,
+ justification,
+ }: UnclaimInput) => {
+ const client = resolveContractClient();
+ if (!client.unclaimBounty) {
+ throw new FcfsError(
+ "missing_contract_bindings",
+ "Unclaim is not available in the current contract bindings.",
+ );
+ }
+ return client.unclaimBounty({
+ creator: creatorAddress,
+ bountyId: toBountyIdBigInt(bountyId),
+ justification,
+ });
+ },
+ onSettled: (_result, _error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: bountyKeys.detail(variables.bountyId),
+ });
+ queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
+ },
+ });
+}
diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts
index 780aa6e..48e7b91 100644
--- a/hooks/use-notifications.ts
+++ b/hooks/use-notifications.ts
@@ -89,7 +89,6 @@ export function useNotifications() {
const isEnabled = Boolean(session?.user);
const userId = session?.user?.id ?? null;
- const prevUserIdRef = useRef(userId);
// Initialize state with lazy loading from localStorage
// This runs only once during initial render, avoiding setState in effect
@@ -100,8 +99,9 @@ export function useNotifications() {
// Reset on user change - this is allowed during render as it's a state update
// based on a condition change (userId)
- if (prevUserIdRef.current !== userId) {
- prevUserIdRef.current = userId;
+ const prevHydratedUserIdRef = useRef(userId);
+ if (prevHydratedUserIdRef.current !== userId) {
+ prevHydratedUserIdRef.current = userId;
setNotifications(userId ? loadFromStorage(userId) : []);
}