Skip to content

Commit 267b6b2

Browse files
committed
feat(bounty): submissions review tab (#632)
Fill the management dashboard's Submissions tab: list submitted work from the organizer submissions endpoint (#337) with submitter identity, status, the content link plus documentation/tweet/demo/media, and submitted-at. Respect submissionVisibility — competition work stays sealed (and unfetched) until the deadline. Adds a client-side 'stage for payout' selection that feeds winner selection (#633).
1 parent cb68454 commit 267b6b2

6 files changed

Lines changed: 448 additions & 3 deletions

File tree

components/organization/bounties/manage/BountyManagementDashboard.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
useBountyOverview,
2222
type BountyOperateOverview,
2323
} from '@/features/bounties';
24+
import BountySubmissionsPanel from './BountySubmissionsPanel';
2425

2526
const STATUS_CLASS: Record<string, string> = {
2627
open: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
@@ -155,7 +156,12 @@ export default function BountyManagementDashboard() {
155156
</TabsContent>
156157
)}
157158
<TabsContent value='submissions'>
158-
<TabPlaceholder title='Submissions review' issue='#632' />
159+
<BountySubmissionsPanel
160+
organizationId={organizationId}
161+
bountyId={bountyId}
162+
submissionVisibility={overview.submissionVisibility ?? ''}
163+
submissionDeadline={overview.submissionDeadline ?? null}
164+
/>
159165
</TabsContent>
160166
<TabsContent value='payout'>
161167
<TabPlaceholder title='Winner selection & payout' issue='#633' />
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Link from 'next/link';
5+
import Image from 'next/image';
6+
import {
7+
CheckCircle2,
8+
ExternalLink,
9+
EyeOff,
10+
FileText,
11+
Github,
12+
Loader2,
13+
PlaySquare,
14+
Star,
15+
Trophy,
16+
Twitter,
17+
} from 'lucide-react';
18+
19+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
20+
import { Badge } from '@/components/ui/badge';
21+
import { BoundlessButton } from '@/components/buttons';
22+
import EmptyState from '@/components/EmptyState';
23+
import { DueCountdown } from '@/components/bounties/DueCountdown';
24+
import {
25+
useBountySubmissions,
26+
type OrganizerBountySubmission,
27+
} from '@/features/bounties';
28+
29+
const STATUS_CLASS: Record<string, string> = {
30+
pending: 'border-blue-500/30 bg-blue-500/10 text-blue-400',
31+
accepted: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
32+
rejected: 'border-red-500/30 bg-red-500/10 text-red-400',
33+
disputed: 'border-amber-500/30 bg-amber-500/10 text-amber-400',
34+
};
35+
36+
function formatDate(iso: string): string {
37+
return new Date(iso).toLocaleDateString(undefined, {
38+
month: 'short',
39+
day: 'numeric',
40+
year: 'numeric',
41+
});
42+
}
43+
44+
export default function BountySubmissionsPanel({
45+
organizationId,
46+
bountyId,
47+
submissionVisibility,
48+
submissionDeadline,
49+
}: {
50+
organizationId: string;
51+
bountyId: string;
52+
submissionVisibility: string;
53+
submissionDeadline: string | null;
54+
}) {
55+
const [staged, setStaged] = useState<Set<string>>(new Set());
56+
57+
const deadlinePassed = submissionDeadline
58+
? new Date(submissionDeadline).getTime() <= Date.now()
59+
: false;
60+
// Competition submissions stay hidden until the deadline so the organizer
61+
// cannot play favorites mid-flight.
62+
const gated =
63+
submissionVisibility === 'HIDDEN_UNTIL_DEADLINE' && !deadlinePassed;
64+
65+
// Don't even fetch sealed competition work until the deadline.
66+
const { data, isLoading, error } = useBountySubmissions(
67+
organizationId,
68+
bountyId,
69+
{},
70+
{ enabled: !gated }
71+
);
72+
73+
if (gated) {
74+
return (
75+
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 py-16 text-center'>
76+
<EyeOff className='mx-auto mb-3 h-6 w-6 text-zinc-500' />
77+
<p className='text-sm font-medium text-zinc-200'>
78+
Submissions are hidden until the deadline
79+
</p>
80+
<p className='mt-1 text-xs text-zinc-500'>
81+
This is a competition. Work stays sealed so review stays fair.
82+
</p>
83+
{submissionDeadline && (
84+
<div className='mt-3 flex justify-center'>
85+
<DueCountdown
86+
deadline={submissionDeadline}
87+
className='flex items-center gap-1.5 text-xs font-medium text-zinc-300'
88+
/>
89+
</div>
90+
)}
91+
</div>
92+
);
93+
}
94+
95+
if (isLoading) {
96+
return (
97+
<div className='flex items-center justify-center py-16'>
98+
<Loader2 className='h-5 w-5 animate-spin text-zinc-500' />
99+
</div>
100+
);
101+
}
102+
103+
if (error) {
104+
return (
105+
<div className='py-12'>
106+
<EmptyState
107+
title="Couldn't load submissions"
108+
description='Try again in a moment.'
109+
type='compact'
110+
/>
111+
</div>
112+
);
113+
}
114+
115+
const submissions = data?.items ?? [];
116+
117+
if (submissions.length === 0) {
118+
return (
119+
<div className='py-12'>
120+
<EmptyState
121+
title='No submissions yet'
122+
description='Submitted work will appear here for review.'
123+
type='compact'
124+
/>
125+
</div>
126+
);
127+
}
128+
129+
const toggleStage = (id: string) =>
130+
setStaged(prev => {
131+
const next = new Set(prev);
132+
if (next.has(id)) next.delete(id);
133+
else next.add(id);
134+
return next;
135+
});
136+
137+
return (
138+
<div className='space-y-4'>
139+
{staged.size > 0 && (
140+
<div className='border-primary/30 bg-primary/10 flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm'>
141+
<Trophy className='text-primary h-4 w-4' />
142+
<span className='text-zinc-200'>{staged.size} staged for payout</span>
143+
<span className='text-xs text-zinc-500'>
144+
(winner selection + signing lands in #633)
145+
</span>
146+
</div>
147+
)}
148+
149+
{submissions.map(s => (
150+
<SubmissionCard
151+
key={s.id}
152+
submission={s}
153+
staged={staged.has(s.id)}
154+
onToggleStage={() => toggleStage(s.id)}
155+
/>
156+
))}
157+
</div>
158+
);
159+
}
160+
161+
function SubmissionCard({
162+
submission: s,
163+
staged,
164+
onToggleStage,
165+
}: {
166+
submission: OrganizerBountySubmission;
167+
staged: boolean;
168+
onToggleStage: () => void;
169+
}) {
170+
const user = s.submittedBy;
171+
const statusClass =
172+
STATUS_CLASS[s.status] ?? 'border-zinc-700 bg-zinc-800/60 text-zinc-300';
173+
const awarded = s.tierPosition != null;
174+
175+
return (
176+
<div
177+
className={`rounded-2xl border bg-zinc-900/40 p-5 transition-colors ${
178+
staged
179+
? 'border-primary/40 bg-primary/5'
180+
: 'border-zinc-800 hover:border-zinc-700'
181+
}`}
182+
>
183+
<div className='flex items-start justify-between gap-3'>
184+
{/* Submitter */}
185+
<Link
186+
href={user.username ? `/profile/${user.username}` : '#'}
187+
className='group flex items-center gap-2.5'
188+
>
189+
<Avatar className='h-8 w-8'>
190+
<AvatarImage src={user.avatarUrl ?? undefined} alt={user.name} />
191+
<AvatarFallback className='text-xs'>
192+
{user.name.charAt(0).toUpperCase()}
193+
</AvatarFallback>
194+
</Avatar>
195+
<div>
196+
<p className='group-hover:text-primary text-sm font-medium text-white'>
197+
{user.name}
198+
</p>
199+
{user.username && (
200+
<p className='text-xs text-zinc-500'>@{user.username}</p>
201+
)}
202+
</div>
203+
</Link>
204+
205+
<div className='flex items-center gap-2'>
206+
{awarded && (
207+
<Badge
208+
variant='outline'
209+
className='border-primary/30 bg-primary/10 text-primary flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium'
210+
>
211+
<Trophy className='h-3 w-3' />
212+
{ordinal(s.tierPosition as number)}
213+
{s.tierAmount
214+
? ` · ${Number(s.tierAmount).toLocaleString()}`
215+
: ''}
216+
</Badge>
217+
)}
218+
<Badge
219+
variant='outline'
220+
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${statusClass}`}
221+
>
222+
{s.status}
223+
</Badge>
224+
</div>
225+
</div>
226+
227+
{/* Work links */}
228+
<div className='mt-4 flex flex-wrap gap-2'>
229+
{s.contentUri && (
230+
<LinkChip
231+
href={s.contentUri}
232+
icon={<Github className='h-3.5 w-3.5' />}
233+
label='Submission'
234+
primary
235+
/>
236+
)}
237+
{s.documentationUrl && (
238+
<LinkChip
239+
href={s.documentationUrl}
240+
icon={<FileText className='h-3.5 w-3.5' />}
241+
label='Docs'
242+
/>
243+
)}
244+
{s.tweetUrl && (
245+
<LinkChip
246+
href={s.tweetUrl}
247+
icon={<Twitter className='h-3.5 w-3.5' />}
248+
label='Tweet'
249+
/>
250+
)}
251+
{s.demoVideoUrl && (
252+
<LinkChip
253+
href={s.demoVideoUrl}
254+
icon={<PlaySquare className='h-3.5 w-3.5' />}
255+
label='Demo'
256+
/>
257+
)}
258+
</div>
259+
260+
{/* Media */}
261+
{s.mediaUrls.length > 0 && (
262+
<div className='mt-3 flex flex-wrap gap-2'>
263+
{s.mediaUrls.map(url => (
264+
<a
265+
key={url}
266+
href={url}
267+
target='_blank'
268+
rel='noreferrer'
269+
className='relative h-16 w-24 overflow-hidden rounded-lg border border-zinc-800'
270+
>
271+
<Image
272+
src={url}
273+
alt='Submission media'
274+
fill
275+
unoptimized
276+
className='object-cover'
277+
/>
278+
</a>
279+
))}
280+
</div>
281+
)}
282+
283+
{/* Footer */}
284+
<div className='mt-4 flex items-center justify-between border-t border-zinc-800 pt-3'>
285+
<span className='text-xs text-zinc-500'>
286+
Submitted {formatDate(s.createdAt)}
287+
{s.escrowAnchorStatus && s.escrowAnchorStatus !== 'active' && (
288+
<span className='ml-2 text-amber-400'>
289+
({s.escrowAnchorStatus.replace(/_/g, ' ')})
290+
</span>
291+
)}
292+
</span>
293+
<BoundlessButton
294+
variant='outline'
295+
size='sm'
296+
onClick={onToggleStage}
297+
className={staged ? 'border-primary text-primary' : 'text-zinc-300'}
298+
>
299+
{staged ? (
300+
<>
301+
<CheckCircle2 className='mr-1.5 h-3.5 w-3.5' />
302+
Staged
303+
</>
304+
) : (
305+
<>
306+
<Star className='mr-1.5 h-3.5 w-3.5' />
307+
Stage for payout
308+
</>
309+
)}
310+
</BoundlessButton>
311+
</div>
312+
</div>
313+
);
314+
}
315+
316+
function LinkChip({
317+
href,
318+
icon,
319+
label,
320+
primary,
321+
}: {
322+
href: string;
323+
icon: React.ReactNode;
324+
label: string;
325+
primary?: boolean;
326+
}) {
327+
return (
328+
<a
329+
href={href}
330+
target='_blank'
331+
rel='noreferrer'
332+
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
333+
primary
334+
? 'border-primary/30 bg-primary/10 text-primary hover:bg-primary/20'
335+
: 'border-zinc-800 bg-zinc-900/50 text-zinc-300 hover:border-zinc-700'
336+
}`}
337+
>
338+
{icon}
339+
{label}
340+
<ExternalLink className='h-3 w-3 opacity-60' />
341+
</a>
342+
);
343+
}
344+
345+
const ordinal = (n: number): string => {
346+
const s = ['th', 'st', 'nd', 'rd'];
347+
const v = n % 100;
348+
return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`;
349+
};

features/bounties/api/keys.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,16 @@ export const bountyKeys = {
2323
myActivity: () => [...bountyKeys.all, 'my-activity'] as const,
2424
overview: (organizationId: string, bountyId: string) =>
2525
[...bountyKeys.all, 'overview', organizationId, bountyId] as const,
26+
orgSubmissions: (
27+
organizationId: string,
28+
bountyId: string,
29+
params: Record<string, unknown> = {}
30+
) =>
31+
[
32+
...bountyKeys.all,
33+
'org-submissions',
34+
organizationId,
35+
bountyId,
36+
params,
37+
] as const,
2638
};

0 commit comments

Comments
 (0)