diff --git a/components/bounties/SealedUntilDeadline.tsx b/components/bounties/SealedUntilDeadline.tsx new file mode 100644 index 00000000..0bbe7545 --- /dev/null +++ b/components/bounties/SealedUntilDeadline.tsx @@ -0,0 +1,31 @@ +import { EyeOff } from 'lucide-react'; + +import { DueCountdown } from './DueCountdown'; + +/** + * Sealed-until-deadline card shared by the organizer submissions review and + * payout tabs, so the competition seal looks and behaves the same on both. + */ +export function SealedUntilDeadline({ + deadline, + title, + description, +}: { + deadline: string; + title: string; + description: string; +}) { + return ( +
+ +

{title}

+

{description}

+
+ +
+
+ ); +} diff --git a/components/bounties/detail/submit/BountySubmitForm.tsx b/components/bounties/detail/submit/BountySubmitForm.tsx index ccd7619f..e1665d85 100644 --- a/components/bounties/detail/submit/BountySubmitForm.tsx +++ b/components/bounties/detail/submit/BountySubmitForm.tsx @@ -24,18 +24,14 @@ import { uploadService } from '@/lib/api/upload'; import { useWalletContext } from '@/components/providers/wallet-provider'; import { useSubmitBounty, + ESCROW_PHASE_LABEL, type BountyPublic, - type EscrowRunPhase, } from '@/features/bounties'; -const PHASE_LABEL: Record = { - idle: '', +const PHASE_LABEL = { + ...ESCROW_PHASE_LABEL, starting: 'Preparing submission…', - signing: 'Signing…', - submitting: 'Submitting…', polling: 'Anchoring on-chain…', - completed: 'Confirmed', - failed: 'Failed', }; type Requirements = BountyPublic['submissionRequirements']; diff --git a/components/bounties/use-deadline-passed.ts b/components/bounties/use-deadline-passed.ts new file mode 100644 index 00000000..b4c58474 --- /dev/null +++ b/components/bounties/use-deadline-passed.ts @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Whether an ISO deadline has passed, re-checked every 30s (the DueCountdown + * cadence) so deadline gates unlock while the user stays on the page instead + * of only on remount. A null deadline reports false. + */ +export function useDeadlinePassed(deadline: string | null): boolean { + const [passed, setPassed] = useState(() => + deadline ? new Date(deadline).getTime() <= Date.now() : false + ); + + useEffect(() => { + if (!deadline) { + setPassed(false); + return; + } + const target = new Date(deadline).getTime(); + if (target <= Date.now()) { + setPassed(true); + return; + } + setPassed(false); + const interval = setInterval(() => { + if (Date.now() >= target) setPassed(true); + }, 30_000); + return () => clearInterval(interval); + }, [deadline]); + + return passed; +} diff --git a/components/organization/bounties/manage/BountyManagementDashboard.tsx b/components/organization/bounties/manage/BountyManagementDashboard.tsx index 2662402e..b1d649fa 100644 --- a/components/organization/bounties/manage/BountyManagementDashboard.tsx +++ b/components/organization/bounties/manage/BountyManagementDashboard.tsx @@ -25,6 +25,7 @@ import { } from '@/features/bounties'; import { ordinal } from '@/lib/utils'; import BountySubmissionsPanel from './BountySubmissionsPanel'; +import BountyPayoutPanel from './BountyPayoutPanel'; export default function BountyManagementDashboard() { const params = useParams<{ id: string; bountyId: string }>(); @@ -171,7 +172,12 @@ export default function BountyManagementDashboard() { /> - + diff --git a/components/organization/bounties/manage/BountyPayoutPanel.tsx b/components/organization/bounties/manage/BountyPayoutPanel.tsx new file mode 100644 index 00000000..d8e33907 --- /dev/null +++ b/components/organization/bounties/manage/BountyPayoutPanel.tsx @@ -0,0 +1,430 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { CheckCircle2, ExternalLink, Loader2, Trophy } from 'lucide-react'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { BoundlessButton } from '@/components/buttons'; +import EmptyState from '@/components/EmptyState'; +import { SealedUntilDeadline } from '@/components/bounties/SealedUntilDeadline'; +import { useDeadlinePassed } from '@/components/bounties/use-deadline-passed'; +import { + useAllBountySubmissions, + ESCROW_PHASE_LABEL, + type BountyOperateOverview, + type BountyWinnerSelection, + type OrganizerBountySubmission, +} from '@/features/bounties'; +import { useBountyPayout } from '@/hooks/use-bounty-payout'; +import { formatPublicKey, ordinal } from '@/lib/utils'; +import { getTransactionExplorerUrl } from '@/lib/wallet-utils'; + +const PHASE_LABEL = { + ...ESCROW_PHASE_LABEL, + starting: 'Preparing payout…', + polling: 'Paying winners on-chain…', +}; + +const NONE = '__none__'; + +/** Token amounts arrive as decimal strings; show full Stellar precision. */ +function formatAmount(amount: string | number): string { + const n = Number(amount); + return Number.isFinite(n) + ? n.toLocaleString(undefined, { maximumFractionDigits: 7 }) + : String(amount); +} + +export default function BountyPayoutPanel({ + organizationId, + bountyId, + overview, + staged, +}: { + organizationId: string; + bountyId: string; + overview: BountyOperateOverview; + /** Submissions staged on the Submissions tab; pinned first in the pickers. */ + staged: Set; +}) { + const isCompleted = overview.status === 'completed'; + const deadlinePassed = useDeadlinePassed(overview.submissionDeadline ?? null); + // UX gate only, mirroring the Submissions tab. Publish validation guarantees + // live competitions have a deadline, so a null deadline does not gate. + const gated = + overview.submissionVisibility === 'HIDDEN_UNTIL_DEADLINE' && + overview.submissionDeadline != null && + !deadlinePassed; + + // Winner selection must see the COMPLETE pool, never one review page. + const { + data: allSubmissions, + isLoading, + error, + } = useAllBountySubmissions(organizationId, bountyId, { enabled: !gated }); + const submissions = useMemo(() => allSubmissions ?? [], [allSubmissions]); + + const payout = useBountyPayout({ organizationId, bountyId }); + + // position -> submissionId + const [assignments, setAssignments] = useState>({}); + const [confirmOpen, setConfirmOpen] = useState(false); + + const prizeTiers = useMemo( + () => overview.prizeTiers.slice().sort((a, b) => a.position - b.position), + [overview.prizeTiers] + ); + + // Payable: anchored on-chain, not rejected or disputed in review, and with + // a payout address. (Server-side validation of the review axis is pending; + // the UI must not offer submissions the organizer has turned down.) + const eligible = useMemo( + () => + submissions.filter( + s => + s.escrowAnchorStatus === 'active' && + s.status !== 'rejected' && + s.status !== 'disputed' && + !!s.applicantAddress + ), + [submissions] + ); + + // Single source of truth for what the payout will contain: a tier counts + // only while its assigned submission is still payable, so the summary, the + // request body, and the total can never disagree. + const resolved = useMemo(() => { + const out: Array<{ + position: number; + amount: string; + sub: OrganizerBountySubmission; + }> = []; + for (const tier of prizeTiers) { + const sub = eligible.find(s => s.id === assignments[tier.position]); + if (sub) { + out.push({ + position: tier.position, + amount: String(tier.amount), + sub, + }); + } + } + return out; + }, [assignments, prizeTiers, eligible]); + + const selections: BountyWinnerSelection[] = resolved.map(r => ({ + applicantAddress: r.sub.applicantAddress as string, + position: r.position, + })); + const totalPayout = resolved.reduce((sum, r) => sum + Number(r.amount), 0); + // The on-chain op is one-shot and completion strands unpaid tiers' escrow, + // so every tier must have a winner before the payout can run. + const allAssigned = + prizeTiers.length > 0 && resolved.length === prizeTiers.length; + + // ── Completed: show the winners the backend recorded ── + if (isCompleted) { + const winners = submissions + .filter(s => s.tierPosition != null) + .sort((a, b) => (a.tierPosition as number) - (b.tierPosition as number)); + return ( +
+
+ +
+

Paid out

+

+ This bounty is completed and rewards were pushed on-chain. +

+
+
+ {winners.length === 0 ? ( + + ) : ( + winners.map(w => ( + + )) + )} +
+ ); + } + + if (gated && overview.submissionDeadline) { + return ( + + ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + if (prizeTiers.length === 0) { + return ( + + ); + } + + if (eligible.length === 0) { + return ( + + ); + } + + const setWinner = (position: number, submissionId: string) => + setAssignments(prev => { + const next = { ...prev }; + if (submissionId === NONE) delete next[position]; + else next[position] = submissionId; + return next; + }); + + // One applicant can win at most one tier: hide submissions whose id OR + // payout address is already resolved to another tier (the backend rejects + // duplicate addresses, so don't offer them). + const optionsFor = (position: number) => { + const takenIds = new Set(); + const takenAddresses = new Set(); + for (const r of resolved) { + if (r.position === position) continue; + takenIds.add(r.sub.id); + if (r.sub.applicantAddress) takenAddresses.add(r.sub.applicantAddress); + } + return eligible + .filter( + s => + !takenIds.has(s.id) && + !(s.applicantAddress && takenAddresses.has(s.applicantAddress)) + ) + .slice() + .sort((a, b) => Number(staged.has(b.id)) - Number(staged.has(a.id))); + }; + + const running = payout.isRunning; + + return ( +
+

+ Assign a submission to every prize tier, then pay out in one signed + transaction. On settle the bounty completes and rewards go on-chain. +

+ +
+ {prizeTiers.map(tier => ( +
+
+ +
+

+ {ordinal(tier.position)} place +

+

+ {formatAmount(tier.amount)} {overview.rewardCurrency} +

+
+
+
+ +
+
+ ))} +
+ + {payout.isCompleted ? ( +
+
+ + Winners paid out. The bounty is now completed. +
+ {payout.txHash && ( + + View payout transaction + + + )} +
+ ) : running ? ( +
+ + {PHASE_LABEL[payout.phase] || 'Working…'} +
+ ) : ( +
+ + {allAssigned + ? `Paying ${formatAmount(totalPayout)} ${overview.rewardCurrency} to ${resolved.length} winner${resolved.length > 1 ? 's' : ''}` + : `Assign a winner to every tier (${resolved.length}/${prizeTiers.length}). The payout runs once; unassigned tiers can never be paid afterwards.`} + + setConfirmOpen(true)} + > + Select winners & pay + +
+ )} + + {/* Review + confirm before the irreversible on-chain payout. */} + + + + Confirm payout + + This pays every winner on-chain in one transaction. It cannot be + undone, changed, or run again. + + +
+ {resolved.map(r => ( +
+ + {ordinal(r.position)} place · {r.sub.submittedBy.name} + + + + {formatAmount(r.amount)} {overview.rewardCurrency} + + + {formatPublicKey(r.sub.applicantAddress as string)} + + +
+ ))} +
+ Total + + {formatAmount(totalPayout)} {overview.rewardCurrency} + +
+
+ + setConfirmOpen(false)} + > + Cancel + + { + setConfirmOpen(false); + void payout.selectWinners(selections); + }} + > + Confirm & pay + + +
+
+
+ ); +} + +function WinnerRow({ + s, + currency, +}: { + s: OrganizerBountySubmission; + currency: string; +}) { + const user = s.submittedBy; + return ( +
+
+ + + + {user.name.charAt(0).toUpperCase()} + + +
+

{user.name}

+

+ {ordinal(s.tierPosition as number)} place +

+
+
+ + {s.tierAmount ? `${formatAmount(s.tierAmount)} ${currency}` : ''} + +
+ ); +} diff --git a/components/organization/bounties/manage/BountySubmissionsPanel.tsx b/components/organization/bounties/manage/BountySubmissionsPanel.tsx index 63991188..849e21c3 100644 --- a/components/organization/bounties/manage/BountySubmissionsPanel.tsx +++ b/components/organization/bounties/manage/BountySubmissionsPanel.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { CheckCircle2, ExternalLink, - EyeOff, FileText, Github, Loader2, @@ -20,8 +19,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { BoundlessButton } from '@/components/buttons'; import EmptyState from '@/components/EmptyState'; -import { DueCountdown } from '@/components/bounties/DueCountdown'; +import { SealedUntilDeadline } from '@/components/bounties/SealedUntilDeadline'; import { submissionStatusClass } from '@/components/bounties/statusClass'; +import { useDeadlinePassed } from '@/components/bounties/use-deadline-passed'; import { useBountySubmissions, type BountyOperateOverview, @@ -66,22 +66,7 @@ export default function BountySubmissionsPanel({ onToggleStage: (id: string) => void; }) { const [page, setPage] = useState(1); - const [deadlinePassed, setDeadlinePassed] = useState(() => - submissionDeadline - ? new Date(submissionDeadline).getTime() <= Date.now() - : false - ); - - // Re-check on the same cadence as DueCountdown so the gate opens while the - // organizer is sitting on the tab, instead of only on remount. - useEffect(() => { - if (!submissionDeadline || deadlinePassed) return; - const target = new Date(submissionDeadline).getTime(); - const interval = setInterval(() => { - if (Date.now() >= target) setDeadlinePassed(true); - }, 30_000); - return () => clearInterval(interval); - }, [submissionDeadline, deadlinePassed]); + const deadlinePassed = useDeadlinePassed(submissionDeadline); // Keep competition work out of the UI until the deadline. Publish validation // guarantees a deadline for live competitions; without one there is nothing @@ -99,23 +84,13 @@ export default function BountySubmissionsPanel({ { params: { page, limit: PAGE_SIZE }, enabled: !gated } ); - if (gated) { + if (gated && submissionDeadline) { return ( -
- -

- Submissions are hidden until the deadline -

-

- This is a competition. Work stays sealed so review stays fair. -

-
- -
-
+ ); } diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts index 52e2a3e9..5df7fb83 100644 --- a/features/bounties/api/keys.ts +++ b/features/bounties/api/keys.ts @@ -37,4 +37,11 @@ export const bountyKeys = { bountyId, params, ] as const, + orgSubmissionsAll: (organizationId: string, bountyId: string) => + [ + ...bountyKeys.all, + 'org-submissions-all', + organizationId, + bountyId, + ] as const, }; diff --git a/features/bounties/api/organizer-dashboard-client.ts b/features/bounties/api/organizer-dashboard-client.ts index c2d6bcee..995e5103 100644 --- a/features/bounties/api/organizer-dashboard-client.ts +++ b/features/bounties/api/organizer-dashboard-client.ts @@ -54,3 +54,23 @@ export const listBountySubmissions = async ( { params: { path: { organizationId, bountyId }, query: params } } ) ); + +/** + * Every submission on a bounty, across all pages. The backend caps `limit` + * at 50, so reads that need the COMPLETE set (winner selection must never + * pick from a truncated pool) page until `total` is reached. + */ +export const listAllBountySubmissions = async ( + organizationId: string, + bountyId: string +): Promise => { + const items: OrganizerBountySubmission[] = []; + for (let page = 1; ; page++) { + const res = await listBountySubmissions(organizationId, bountyId, { + page, + limit: 50, + }); + items.push(...res.items); + if (items.length >= res.total || res.items.length === 0) return items; + } +}; diff --git a/features/bounties/api/use-escrow.ts b/features/bounties/api/use-escrow.ts index 98067fb7..5d5a5a71 100644 --- a/features/bounties/api/use-escrow.ts +++ b/features/bounties/api/use-escrow.ts @@ -167,6 +167,21 @@ export type EscrowRunPhase = | 'completed' | 'failed'; +/** + * Base user-facing labels for the runner phases. Flows spread this and + * override the flow-specific verbs (usually `starting` and `polling`), so a + * new phase only needs wiring here and every consumer stays exhaustive. + */ +export const ESCROW_PHASE_LABEL: Record = { + idle: '', + starting: 'Preparing…', + signing: 'Signing…', + submitting: 'Submitting…', + polling: 'Confirming on-chain…', + completed: 'Confirmed', + failed: 'Failed', +}; + export interface UseEscrowOpRunnerOptions { /** * Signer for the EXTERNAL path. When the start op returns PENDING_SIGN with diff --git a/features/bounties/api/use-organizer-dashboard.ts b/features/bounties/api/use-organizer-dashboard.ts index a14784f2..0cf1dc7f 100644 --- a/features/bounties/api/use-organizer-dashboard.ts +++ b/features/bounties/api/use-organizer-dashboard.ts @@ -5,8 +5,10 @@ import { useQuery } from '@tanstack/react-query'; import { bountyKeys } from './keys'; import { getBountyOverview, + listAllBountySubmissions, listBountySubmissions, type BountyOperateOverview, + type OrganizerBountySubmission, type OrganizerBountySubmissionList, type OrganizerSubmissionsParams, } from './organizer-dashboard-client'; @@ -56,3 +58,24 @@ export function useBountySubmissions( enabled: !!organizationId && !!bountyId && (options.enabled ?? true), }); } + +/** + * The COMPLETE submission set for a bounty (pages through the capped list + * endpoint). Winner selection reads this so the payout pool is never a + * truncated page. Same UX-only caveat as useBountySubmissions. + */ +export function useAllBountySubmissions( + organizationId: string | undefined, + bountyId: string | undefined, + options: { enabled?: boolean } = {} +) { + return useQuery({ + queryKey: bountyKeys.orgSubmissionsAll( + organizationId ?? '', + bountyId ?? '' + ), + queryFn: () => + listAllBountySubmissions(organizationId as string, bountyId as string), + enabled: !!organizationId && !!bountyId && (options.enabled ?? true), + }); +} diff --git a/features/bounties/index.ts b/features/bounties/index.ts index 6e3bfa0b..7b5e10fb 100644 --- a/features/bounties/index.ts +++ b/features/bounties/index.ts @@ -112,6 +112,7 @@ export { usePublishBountyEscrow, useSelectBountyWinners, useCancelBountyEscrow, + ESCROW_PHASE_LABEL, } from './api/use-escrow'; export type { EscrowOpScope, @@ -187,6 +188,7 @@ export { export { getBountyOverview, listBountySubmissions, + listAllBountySubmissions, } from './api/organizer-dashboard-client'; export type { BountyOperateOverview, @@ -203,6 +205,7 @@ export type { export { useBountyOverview, useBountySubmissions, + useAllBountySubmissions, } from './api/use-organizer-dashboard'; // Participant hooks (React Query). diff --git a/hooks/use-bounty-payout.ts b/hooks/use-bounty-payout.ts new file mode 100644 index 00000000..3d1e97bf --- /dev/null +++ b/hooks/use-bounty-payout.ts @@ -0,0 +1,106 @@ +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +import { useWalletContext } from '@/components/providers/wallet-provider'; +import { signXdrWithKit } from '@/lib/wallet/wallet-kit'; +import { + useEscrowOpRunner, + useSelectBountyWinners, + type BountyWinnerSelection, + type FundingMode, + type SelectBountyWinnersRequest, +} from '@/features/bounties'; + +interface UseBountyPayoutProps { + organizationId: string; + bountyId: string; + /** Signing path. Defaults to MANAGED (custodial). */ + fundingMode?: FundingMode; + /** For EXTERNAL: the connected wallet that signs (owner of the escrow). */ + externalOwnerAddress?: string | null; +} + +/** + * Drives the single `select_winners` op that pays winners on-chain. Mirrors + * useBountyPublish: MANAGED signs server-side (op comes back PENDING_CONFIRM), + * EXTERNAL returns an unsigned XDR the connected wallet signs. On settle the + * backend subscriber pushes USDC, marks winning submissions, and flips the + * bounty to COMPLETED automatically. + * + * LIMITATION: `ownerAddress` must match the bounty's on-chain owner, but + * SelectBountyWinnersDto has no `sourceWalletId`, so bounties published from + * an org treasury wallet cannot be paid through this endpoint yet; the MANAGED + * default only works for bounties funded from the caller's personal managed + * wallet. The backend stores `escrowOwnerAddress` per bounty; resolving the + * owner server-side (as the hackathon select-winners flow does) is tracked as + * backend work. + */ +export const useBountyPayout = ({ + organizationId, + bountyId, + fundingMode = 'MANAGED', + externalOwnerAddress, +}: UseBountyPayoutProps) => { + const { walletAddress } = useWalletContext(); + const isExternal = fundingMode === 'EXTERNAL'; + const ownerAddress = isExternal + ? (externalOwnerAddress ?? null) + : walletAddress; + + const selectMutation = useSelectBountyWinners(organizationId, bountyId); + const runner = useEscrowOpRunner( + { kind: 'organizer', organizationId, bountyId }, + isExternal ? { signXdr: signXdrWithKit } : undefined + ); + + // Toast each settle exactly once; the runner keeps its terminal flags set + // after a run, so this ref is what arms the next run's notifications. + const finalizedRef = useRef(false); + + useEffect(() => { + if (finalizedRef.current) return; + if (runner.isCompleted) { + finalizedRef.current = true; + toast.success('Winners paid out!', { duration: 3000 }); + } else if (runner.isFailed) { + finalizedRef.current = true; + toast.error(runner.error || 'Failed to pay out winners'); + } + }, [runner.isCompleted, runner.isFailed, runner.error]); + + const selectWinners = async ( + selections: BountyWinnerSelection[] + ): Promise => { + if (!organizationId || !bountyId || selections.length === 0) return; + if (!ownerAddress) { + toast.error( + isExternal + ? 'Connect a wallet to sign the payout.' + : 'Please connect your wallet first' + ); + return; + } + + const body: SelectBountyWinnersRequest = { + ownerAddress, + selections, + fundingMode, + }; + + finalizedRef.current = false; + toast.info('Submitting winner selection…'); + + await runner.run(() => selectMutation.mutateAsync(body)); + }; + + return { + selectWinners, + phase: runner.phase, + isRunning: runner.isRunning, + isCompleted: runner.isCompleted, + isFailed: runner.isFailed, + txHash: runner.txHash, + error: runner.error, + reset: runner.reset, + }; +};