diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index fed32e8c..c719e893 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -11,11 +11,14 @@ import { BountyDetailSubmissionsCard } from "./bounty-detail-submissions-card"; import { BountyDetailSkeleton } from "./bounty-detail-bounty-detail-skeleton"; import { useBountyDetail } from "@/hooks/use-bounty-detail"; import { FcfsApprovalPanel } from "@/components/bounty/fcfs-approval-panel"; +import { CompetitionJudging } from "@/components/bounty/competition-judging"; +import type { CompetitionSubmissionEntry } from "@/components/bounty/competition-judging"; import { EscrowDetailPanel } from "../bounty/escrow-detail-panel"; import { RefundStatusTracker } from "../bounty/refund-status"; import { FeeCalculator } from "../bounty/fee-calculator"; import { useEscrowPool } from "@/hooks/use-escrow"; import { authClient } from "@/lib/auth-client"; +import { useDeadlinePassed } from "@/hooks/use-deadline-passed"; import type { CancellationRecord } from "@/types/escrow"; import { MilestoneFunnel } from "@/components/bounty/milestone-funnel"; import { @@ -63,15 +66,16 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { const router = useRouter(); const { data: bounty, isPending, isError, error } = useBountyDetail(bountyId); const { data: pool } = useEscrowPool(bountyId); + const { data: session } = authClient.useSession(); const [cancellationRecord, setCancellationRecord] = useState(null); - const { data: session } = authClient.useSession(); - const handleCancelled = useCallback((record: CancellationRecord) => { setCancellationRecord(record); }, []); + const pastDeadline = useDeadlinePassed(bounty?.bountyWindow?.endDate); + if (isPending) return ; if (isError) { @@ -125,6 +129,17 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { const isCancelled = bounty.status === "CANCELLED" || cancellationRecord !== null; + const isCompetition = bounty.type === "COMPETITION"; + const isCreator = + (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; + const isFinalized = bounty.status === "COMPLETED"; + // submissions is present on BountyQuery (single-bounty query) but not on + // BountyFieldsFragment (list query). The cast is safe here because + // useBountyDetail returns BountyFieldsFragment & Partial. + const competitionSubmissions = + (bounty as { submissions?: CompetitionSubmissionEntry[] | null }) + .submissions ?? []; + return (
{/* Main content */} @@ -185,10 +200,19 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { {!isCancelled && pool && } - {bounty.type !== "FIXED_PRICE" && ( + {bounty.type !== "FIXED_PRICE" && !isCompetition && ( )} {bounty.type === "FIXED_PRICE" && } + {isCompetition && isCreator && (pastDeadline || isFinalized) && ( + + )}
{/* Sidebar */} diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index 9c1a72ef..3aa9dd19 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -9,6 +9,7 @@ import { XCircle, Loader2, Users, + Clock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -27,7 +28,10 @@ import { import { BountyFieldsFragment } from "@/lib/graphql/generated"; import { StatusBadge, TypeBadge } from "./bounty-badges"; import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button"; +import { CompetitionSubmission } from "@/components/bounty/competition-submission"; +import { CompetitionStatus } from "@/components/bounty/competition-status"; import { authClient } from "@/lib/auth-client"; +import { useCompetitionJoinState } from "@/hooks/use-competition-join-state"; import type { CancellationRecord } from "@/types/escrow"; import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog"; import type { Bounty } from "@/types/bounty"; @@ -44,7 +48,6 @@ interface SidebarCTAProps { export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { const [copied, setCopied] = useState(false); - const [isApplying, setIsApplying] = useState(false); const { data: session } = authClient.useSession(); const { @@ -58,10 +61,22 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { const canAct = bounty.status === "OPEN"; const isFcfs = bounty.type === "FIXED_PRICE"; - const isCreator = session?.user?.id === bounty.createdBy; + const isCompetition = bounty.type === "COMPETITION"; + const isCreator = + (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; const canCancel = isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS"); + // claimCount: use backend claimCount when available, fall back to _count.submissions + const claimCount = bounty.claimCount ?? bounty._count?.submissions ?? 0; + const maxParticipants = bounty.maxParticipants ?? null; + const deadline = bounty.bountyWindow?.endDate ?? null; + const isFinalized = bounty.status === "COMPLETED"; + const submissionCount = bounty._count?.submissions ?? 0; + + const { hasJoined, isPastDeadline, joinMutation, handleJoin } = + useCompetitionJoinState(bounty); + const handleCopy = async () => { try { await navigator.clipboard.writeText(window.location.href); @@ -139,39 +154,49 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { + {/* Competition slot count */} + {isCompetition && ( +
+ + + Slots + + + {claimCount} + {maxParticipants != null ? `/${maxParticipants}` : ""} joined + +
+ )} + + {isCompetition && } + {/* CTA */} {isFcfs ? ( - ) : bounty.type === "MULTI_WINNER_MILESTONE" ? ( - (() => { - const occupied = bounty.totalSlotsOccupied ?? 0; - const max = bounty.maxSlots ?? 5; - const isFull = occupied >= max; - return ( - - ); - })() + ) : isCompetition ? ( + hasJoined ? ( + + ) : ( + + ) ) : ( + {/* Competition status + submission panel */} + {isCompetition && ( + <> + + + + )} + {/* Cancel Confirmation Dialog */} @@ -328,10 +378,15 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { const canAct = bounty.status === "OPEN"; const isFcfs = bounty.type === "FIXED_PRICE"; - const isCreator = session?.user?.id === bounty.createdBy; + const isCompetition = bounty.type === "COMPETITION"; + const isCreator = + (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; const canCancel = isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS"); + const { hasJoined, isPastDeadline, joinMutation, handleJoin } = + useCompetitionJoinState(bounty); + const label = () => { if (!canAct) { switch (bounty.status) { @@ -350,6 +405,26 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
{isFcfs ? ( + ) : isCompetition ? ( + ) : (
diff --git a/components/bounty/competition-judging.tsx b/components/bounty/competition-judging.tsx new file mode 100644 index 00000000..36e45bbb --- /dev/null +++ b/components/bounty/competition-judging.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Trophy, Award, CheckCircle2, Lock } 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 { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + useApproveContestWinner, + useFinalizeContest, +} from "@/hooks/use-competition-bounty"; + +// Stellar assets use 7 decimal places (1 XLM = 10_000_000 stroops). +// If the platform ever supports assets with different precision, derive +// this from the currency symbol instead of hardcoding. +const STELLAR_ASSET_SCALE = 1e7; + +interface Submission { + id: string; + submittedBy: string; + submittedByUser?: { name?: string | null; image?: string | null } | null; + githubPullRequestUrl?: string | null; + // "APPROVED" status comes from the backend after approve_contest_winner + status: string; +} + +export type { Submission as CompetitionSubmissionEntry }; + +interface CompetitionJudgingProps { + bountyId: string; + submissions: Submission[]; + isFinalized: boolean; + totalReward: number | null | undefined; + currency: string | null | undefined; +} + +export function CompetitionJudging({ + bountyId, + submissions, + isFinalized, + totalReward, + currency, +}: CompetitionJudgingProps) { + const { data: session } = authClient.useSession(); + const approveMutation = useApproveContestWinner(); + const finalizeMutation = useFinalizeContest(); + + const [payouts, setPayouts] = useState>({}); + const [points, setPoints] = useState>({}); + // Optimistic local set — augments backend status for immediate feedback. + // On next query invalidation the backend status takes over. + const [localApproved, setLocalApproved] = useState>(new Set()); + + const walletAddress = + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.walletAddress || + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.address || + null; + + const handleApprove = async (sub: Submission) => { + if (!walletAddress) { + toast.error("Connect your wallet to approve."); + return; + } + const payout = parseFloat(payouts[sub.id] ?? ""); + const pts = parseInt(points[sub.id] ?? "10", 10); + if (!isFinite(payout) || payout <= 0) { + toast.error("Enter a valid payout amount greater than 0."); + return; + } + if (!isFinite(pts) || pts < 0) { + toast.error("Reputation points must be a non-negative number."); + return; + } + try { + await approveMutation.mutateAsync({ + bountyId, + creatorAddress: walletAddress, + winner: sub.submittedBy, + payoutAmount: BigInt(Math.round(payout * STELLAR_ASSET_SCALE)), + points: pts, + }); + setLocalApproved((prev) => new Set(prev).add(sub.id)); + toast.success( + `Payment approved for ${sub.submittedByUser?.name ?? sub.submittedBy}.`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Approval failed."); + } + }; + + const handleFinalize = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to finalize."); + return; + } + try { + await finalizeMutation.mutateAsync({ + bountyId, + creatorAddress: walletAddress, + }); + toast.success("Contest finalized. No further approvals allowed."); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Finalization failed."); + } + }; + + if (submissions.length === 0) { + return ( +
+ No submissions to review yet. +
+ ); + } + + const pendingWinner = approveMutation.isPending + ? approveMutation.variables?.winner + : null; + + // Any submission approved either by backend status or local optimism + const isApproved = (sub: Submission) => + sub.status === "APPROVED" || localApproved.has(sub.id); + + const anyApproved = + submissions.some((s) => s.status === "APPROVED") || localApproved.size > 0; + + return ( +
+
+

+ + Judge Submissions +

+ + {submissions.length} submission{submissions.length !== 1 ? "s" : ""} + +
+ +
+ {submissions.map((sub, idx) => { + const approved = isApproved(sub); + const name = sub.submittedByUser?.name ?? sub.submittedBy; + const isThisPending = + approveMutation.isPending && pendingWinner === sub.submittedBy; + + return ( +
+
+
+ #{idx + 1} + + {name} + + {approved && ( + + + Approved + + )} +
+ {/* "Top" badge only on the first unapproved entry — not a ranking signal */} + {idx === 0 && !approved && ( + + + First + + )} +
+ + {sub.githubPullRequestUrl && ( + + {sub.githubPullRequestUrl} + + )} + + {!isFinalized && !approved && ( +
+
+ + + setPayouts((p) => ({ ...p, [sub.id]: e.target.value })) + } + className="h-8 text-sm" + disabled={approveMutation.isPending} + /> +
+
+ + + setPoints((p) => ({ ...p, [sub.id]: e.target.value })) + } + className="h-8 text-sm" + disabled={approveMutation.isPending} + /> +
+
+ )} + + {!isFinalized && !approved && ( + + )} +
+ ); + })} +
+ + {!isFinalized && anyApproved && ( + <> + +
+

+ Finalize to close the contest and prevent further approvals. +

+ +
+ + )} + + {isFinalized && ( +
+ + Contest finalized. Results are published. +
+ )} +
+ ); +} diff --git a/components/bounty/competition-status.tsx b/components/bounty/competition-status.tsx new file mode 100644 index 00000000..02e7e856 --- /dev/null +++ b/components/bounty/competition-status.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Users, Clock, Eye, EyeOff, CheckCircle2 } from "lucide-react"; +import { useDeadlinePassed } from "@/hooks/use-deadline-passed"; + +interface CompetitionStatusProps { + // NOTE: claimCount is a pending backend field (not yet in schema). + // Until it ships, this falls back to _count.submissions (submitted entries + // only). A dedicated claimCount on BountyCount is tracked in the backend + // schema backlog — at that point this prop will reflect true join count. + claimCount: number; + maxParticipants?: number | null; + submissionCount: number; + deadline: string | null | undefined; + isFinalized: boolean; +} + +export function CompetitionStatus({ + claimCount, + maxParticipants, + submissionCount, + deadline, + isFinalized, +}: CompetitionStatusProps) { + const pastDeadline = useDeadlinePassed(deadline); + + return ( +
+

+ Competition Status +

+ +
+ {/* Participants */} +
+ + + {claimCount} + {maxParticipants != null ? `/${maxParticipants}` : ""}{" "} + joined + +
+ + {/* Submissions */} +
+ {pastDeadline ? ( + + ) : ( + + )} + + {pastDeadline ? submissionCount : "?"}{" "} + + {pastDeadline ? "revealed" : "hidden"} + + +
+
+ + {/* Phase indicator */} +
+ {isFinalized ? ( + <> + + + Results published + + + ) : pastDeadline ? ( + <> + + + Judging in progress + + + ) : ( + <> + + + Accepting submissions + + + )} +
+
+ ); +} diff --git a/components/bounty/competition-submission.tsx b/components/bounty/competition-submission.tsx new file mode 100644 index 00000000..669c07b7 --- /dev/null +++ b/components/bounty/competition-submission.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Loader2, Lock, Send, Clock } from "lucide-react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { useSubmitContestWork } from "@/hooks/use-competition-bounty"; +import { useDeadlinePassed } from "@/hooks/use-deadline-passed"; + +// Accepts https:// URLs or ipfs:// / Qm... / bafy... CIDs +const VALID_SUBMISSION = + /^(https?:\/\/.+|ipfs:\/\/.+|Qm[1-9A-HJ-NP-Za-km-z]{44,}|bafy[a-z2-7]{50,})$/; + +function isValidSubmission(value: string): boolean { + return VALID_SUBMISSION.test(value.trim()); +} + +interface CompetitionSubmissionProps { + bountyId: string; + deadline: string | null | undefined; + hasJoined: boolean; +} + +function formatCountdown(ms: number): string { + if (ms <= 0) return "Deadline passed"; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d}d ${h}h ${m}m`; + return `${h}h ${m}m ${sec}s`; +} + +export function CompetitionSubmission({ + bountyId, + deadline, + hasJoined, +}: CompetitionSubmissionProps) { + const { data: session } = authClient.useSession(); + const [workCid, setWorkCid] = useState(""); + const submitMutation = useSubmitContestWork(); + const isPastDeadline = useDeadlinePassed(deadline); + + // Countdown display — null on server to avoid hydration mismatch + const [remaining, setRemaining] = useState(null); + useEffect(() => { + if (!deadline) return; + const tick = () => setRemaining(new Date(deadline).getTime() - Date.now()); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [deadline]); + + const walletAddress = + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.walletAddress || + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.address || + null; + + if (!hasJoined) return null; + + const trimmed = workCid.trim(); + const isValid = isValidSubmission(trimmed); + + const handleSubmit = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to submit."); + return; + } + if (!isValid) { + toast.error( + "Enter a valid https:// URL or IPFS CID (ipfs://, Qm…, or bafy…).", + ); + return; + } + try { + await submitMutation.mutateAsync({ + bountyId, + contributorAddress: walletAddress, + workCid: trimmed, + }); + toast.success("Submission recorded on-chain."); + setWorkCid(""); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Submission failed."); + } + }; + + return ( +
+
+

Your Submission

+ {deadline && remaining !== null && ( + + + {formatCountdown(remaining)} + + )} +
+ + {isPastDeadline ? ( +
+ + Submissions are closed. Results will be revealed by the creator. +
+ ) : ( +
+
+ +