Skip to content

Commit f5a1528

Browse files
committed
feat(bounty): winner selection + payout tab (#633)
Fill the management dashboard's Payout & Winners tab: assign anchored submissions to prize tiers (one winner per tier, single-claim = 1, competition = 1-N), then drive the single select-winners escrow op (useBountyPayout, organizer scope; MANAGED signs server-side, EXTERNAL via wallet kit) with progress + payout tx link. On settle the bounty flips to COMPLETED; already-completed bounties show recorded winners. Consumes the submissions review (#632) staging.
1 parent 267b6b2 commit f5a1528

3 files changed

Lines changed: 459 additions & 1 deletion

File tree

components/organization/bounties/manage/BountyManagementDashboard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type BountyOperateOverview,
2323
} from '@/features/bounties';
2424
import BountySubmissionsPanel from './BountySubmissionsPanel';
25+
import BountyPayoutPanel from './BountyPayoutPanel';
2526

2627
const STATUS_CLASS: Record<string, string> = {
2728
open: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
@@ -164,7 +165,11 @@ export default function BountyManagementDashboard() {
164165
/>
165166
</TabsContent>
166167
<TabsContent value='payout'>
167-
<TabPlaceholder title='Winner selection & payout' issue='#633' />
168+
<BountyPayoutPanel
169+
organizationId={organizationId}
170+
bountyId={bountyId}
171+
overview={overview}
172+
/>
168173
</TabsContent>
169174
<TabsContent value='settings'>
170175
<TabPlaceholder title='Settings & cancel / refund' issue='#634' />
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
'use client';
2+
3+
import { useMemo, useState } from 'react';
4+
import {
5+
CheckCircle2,
6+
ExternalLink,
7+
EyeOff,
8+
Loader2,
9+
Trophy,
10+
} from 'lucide-react';
11+
12+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
13+
import {
14+
Select,
15+
SelectContent,
16+
SelectItem,
17+
SelectTrigger,
18+
SelectValue,
19+
} from '@/components/ui/select';
20+
import { BoundlessButton } from '@/components/buttons';
21+
import EmptyState from '@/components/EmptyState';
22+
import { DueCountdown } from '@/components/bounties/DueCountdown';
23+
import {
24+
useBountySubmissions,
25+
type BountyOperateOverview,
26+
type BountyWinnerSelection,
27+
type OrganizerBountySubmission,
28+
} from '@/features/bounties';
29+
import { useBountyPayout } from '@/hooks/use-bounty-payout';
30+
31+
const PHASE_LABEL: Record<string, string> = {
32+
starting: 'Preparing payout…',
33+
signing: 'Signing…',
34+
submitting: 'Submitting…',
35+
polling: 'Paying winners on-chain…',
36+
};
37+
38+
const NONE = '__none__';
39+
40+
export default function BountyPayoutPanel({
41+
organizationId,
42+
bountyId,
43+
overview,
44+
}: {
45+
organizationId: string;
46+
bountyId: string;
47+
overview: BountyOperateOverview;
48+
}) {
49+
const isCompleted = overview.status === 'completed';
50+
const deadlinePassed = overview.submissionDeadline
51+
? new Date(overview.submissionDeadline).getTime() <= Date.now()
52+
: false;
53+
const gated =
54+
overview.submissionVisibility === 'HIDDEN_UNTIL_DEADLINE' &&
55+
!deadlinePassed;
56+
57+
const { data, isLoading } = useBountySubmissions(
58+
organizationId,
59+
bountyId,
60+
{},
61+
{ enabled: !gated }
62+
);
63+
const submissions = useMemo(() => data?.items ?? [], [data]);
64+
65+
const payout = useBountyPayout({ organizationId, bountyId });
66+
67+
// position -> submissionId
68+
const [assignments, setAssignments] = useState<Record<number, string>>({});
69+
70+
const prizeTiers = useMemo(
71+
() => overview.prizeTiers.slice().sort((a, b) => a.position - b.position),
72+
[overview.prizeTiers]
73+
);
74+
75+
// Only anchored (non-withdrawn) submissions can be paid.
76+
const eligible = useMemo(
77+
() => submissions.filter(s => s.escrowAnchorStatus === 'active'),
78+
[submissions]
79+
);
80+
81+
const selections: BountyWinnerSelection[] = useMemo(() => {
82+
const out: BountyWinnerSelection[] = [];
83+
for (const tier of prizeTiers) {
84+
const subId = assignments[tier.position];
85+
const sub = eligible.find(s => s.id === subId);
86+
if (sub?.applicantAddress) {
87+
out.push({
88+
applicantAddress: sub.applicantAddress,
89+
position: tier.position,
90+
});
91+
}
92+
}
93+
return out;
94+
}, [assignments, prizeTiers, eligible]);
95+
96+
const totalPayout = useMemo(
97+
() =>
98+
prizeTiers
99+
.filter(t => assignments[t.position])
100+
.reduce((sum, t) => sum + Number(t.amount), 0),
101+
[assignments, prizeTiers]
102+
);
103+
104+
// ── Completed: show the winners the backend recorded ──
105+
if (isCompleted) {
106+
const winners = submissions
107+
.filter(s => s.tierPosition != null)
108+
.sort((a, b) => (a.tierPosition as number) - (b.tierPosition as number));
109+
return (
110+
<div className='space-y-4'>
111+
<div className='flex items-center gap-2 rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3'>
112+
<CheckCircle2 className='h-5 w-5 text-emerald-400' />
113+
<div>
114+
<p className='text-sm font-medium text-white'>Paid out</p>
115+
<p className='text-xs text-zinc-400'>
116+
This bounty is completed and rewards were pushed on-chain.
117+
</p>
118+
</div>
119+
</div>
120+
{winners.length === 0 ? (
121+
<EmptyState
122+
title='No winner records'
123+
description='This bounty completed without recorded winners.'
124+
type='compact'
125+
/>
126+
) : (
127+
winners.map(w => (
128+
<WinnerRow key={w.id} s={w} currency={overview.rewardCurrency} />
129+
))
130+
)}
131+
</div>
132+
);
133+
}
134+
135+
if (gated) {
136+
return (
137+
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 py-16 text-center'>
138+
<EyeOff className='mx-auto mb-3 h-6 w-6 text-zinc-500' />
139+
<p className='text-sm font-medium text-zinc-200'>
140+
Winner selection opens at the deadline
141+
</p>
142+
<p className='mt-1 text-xs text-zinc-500'>
143+
Competition submissions stay sealed until then.
144+
</p>
145+
{overview.submissionDeadline && (
146+
<div className='mt-3 flex justify-center'>
147+
<DueCountdown
148+
deadline={overview.submissionDeadline}
149+
className='flex items-center gap-1.5 text-xs font-medium text-zinc-300'
150+
/>
151+
</div>
152+
)}
153+
</div>
154+
);
155+
}
156+
157+
if (isLoading) {
158+
return (
159+
<div className='flex items-center justify-center py-16'>
160+
<Loader2 className='h-5 w-5 animate-spin text-zinc-500' />
161+
</div>
162+
);
163+
}
164+
165+
if (prizeTiers.length === 0) {
166+
return (
167+
<EmptyState
168+
title='No prize tiers'
169+
description='This bounty has no prize tiers to award.'
170+
type='compact'
171+
/>
172+
);
173+
}
174+
175+
if (eligible.length === 0) {
176+
return (
177+
<EmptyState
178+
title='No eligible submissions'
179+
description='Winners are chosen from anchored submissions. None are available yet.'
180+
type='compact'
181+
/>
182+
);
183+
}
184+
185+
const setWinner = (position: number, submissionId: string) =>
186+
setAssignments(prev => {
187+
const next = { ...prev };
188+
if (submissionId === NONE) delete next[position];
189+
else next[position] = submissionId;
190+
return next;
191+
});
192+
193+
const running = payout.isRunning;
194+
195+
return (
196+
<div className='space-y-5'>
197+
<p className='text-sm text-zinc-400'>
198+
Assign a submission to each prize tier, then pay out in one signed
199+
transaction. On settle the bounty completes and rewards go on-chain.
200+
</p>
201+
202+
<div className='space-y-3'>
203+
{prizeTiers.map(tier => {
204+
const assignedElsewhere = new Set(
205+
Object.entries(assignments)
206+
.filter(([pos]) => Number(pos) !== tier.position)
207+
.map(([, id]) => id)
208+
);
209+
const options = eligible.filter(s => !assignedElsewhere.has(s.id));
210+
return (
211+
<div
212+
key={tier.position}
213+
className='flex items-center gap-4 rounded-2xl border border-zinc-800 bg-zinc-900/40 p-4'
214+
>
215+
<div className='flex items-center gap-2'>
216+
<Trophy className='text-primary h-4 w-4' />
217+
<div>
218+
<p className='text-sm font-medium text-white'>
219+
{ordinal(tier.position)} place
220+
</p>
221+
<p className='text-primary text-xs font-semibold'>
222+
{Number(tier.amount).toLocaleString()}{' '}
223+
{overview.rewardCurrency}
224+
</p>
225+
</div>
226+
</div>
227+
<div className='flex-1'>
228+
<Select
229+
value={assignments[tier.position] ?? NONE}
230+
onValueChange={v => setWinner(tier.position, v)}
231+
disabled={running}
232+
>
233+
<SelectTrigger className='border-zinc-800 bg-zinc-900/50 text-white'>
234+
<SelectValue placeholder='Choose a winner' />
235+
</SelectTrigger>
236+
<SelectContent className='border-zinc-800 bg-zinc-900 text-white'>
237+
<SelectItem value={NONE}>No winner</SelectItem>
238+
{options.map(s => (
239+
<SelectItem key={s.id} value={s.id}>
240+
{s.submittedBy.name}
241+
{s.submittedBy.username
242+
? ` (@${s.submittedBy.username})`
243+
: ''}
244+
</SelectItem>
245+
))}
246+
</SelectContent>
247+
</Select>
248+
</div>
249+
</div>
250+
);
251+
})}
252+
</div>
253+
254+
{payout.isCompleted ? (
255+
<div className='space-y-2 rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-4'>
256+
<div className='flex items-center gap-2 text-sm text-white'>
257+
<CheckCircle2 className='h-4 w-4 text-emerald-400' />
258+
Winners paid out. The bounty is now completed.
259+
</div>
260+
{payout.txHash && (
261+
<a
262+
href={`https://stellar.expert/explorer/testnet/tx/${payout.txHash}`}
263+
target='_blank'
264+
rel='noreferrer'
265+
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
266+
>
267+
View payout transaction
268+
<ExternalLink className='h-3 w-3' />
269+
</a>
270+
)}
271+
</div>
272+
) : running ? (
273+
<div className='flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm text-zinc-300'>
274+
<Loader2 className='text-primary h-4 w-4 animate-spin' />
275+
{PHASE_LABEL[payout.phase] || 'Working…'}
276+
</div>
277+
) : (
278+
<div className='flex items-center justify-between gap-3 border-t border-zinc-800 pt-4'>
279+
<span className='text-sm text-zinc-400'>
280+
{selections.length > 0
281+
? `Paying ${totalPayout.toLocaleString()} ${overview.rewardCurrency} to ${selections.length} winner${selections.length > 1 ? 's' : ''}`
282+
: 'Assign at least one winner'}
283+
</span>
284+
<BoundlessButton
285+
disabled={selections.length === 0}
286+
onClick={() => void payout.selectWinners(selections)}
287+
>
288+
Select winners &amp; pay
289+
</BoundlessButton>
290+
</div>
291+
)}
292+
</div>
293+
);
294+
}
295+
296+
function WinnerRow({
297+
s,
298+
currency,
299+
}: {
300+
s: OrganizerBountySubmission;
301+
currency: string;
302+
}) {
303+
const user = s.submittedBy;
304+
return (
305+
<div className='flex items-center justify-between rounded-2xl border border-zinc-800 bg-zinc-900/40 p-4'>
306+
<div className='flex items-center gap-2.5'>
307+
<Avatar className='h-8 w-8'>
308+
<AvatarImage src={user.avatarUrl ?? undefined} alt={user.name} />
309+
<AvatarFallback className='text-xs'>
310+
{user.name.charAt(0).toUpperCase()}
311+
</AvatarFallback>
312+
</Avatar>
313+
<div>
314+
<p className='text-sm font-medium text-white'>{user.name}</p>
315+
<p className='text-xs text-zinc-500'>
316+
{ordinal(s.tierPosition as number)} place
317+
</p>
318+
</div>
319+
</div>
320+
<span className='text-primary text-sm font-semibold'>
321+
{s.tierAmount
322+
? `${Number(s.tierAmount).toLocaleString()} ${currency}`
323+
: ''}
324+
</span>
325+
</div>
326+
);
327+
}
328+
329+
const ordinal = (n: number): string => {
330+
const s = ['th', 'st', 'nd', 'rd'];
331+
const v = n % 100;
332+
return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`;
333+
};

0 commit comments

Comments
 (0)