Skip to content

Commit dced3cf

Browse files
committed
feat(bounty): applications review tab (#631)
Fill the management dashboard's Applications tab (application entry types): list applications with proposal content (short/full, portfolio, estimated days, qualifications, video), applicant + status. Mode-aware actions on submitted applications — single-claim selects one, competition shortlists N (respecting shortlistSize), both can decline with a reason. Adds the organizer applications data layer (list + select/shortlist/decline).
1 parent 9950d1f commit dced3cf

6 files changed

Lines changed: 564 additions & 1 deletion

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { toast } from 'sonner';
5+
import {
6+
Calendar,
7+
CheckCircle2,
8+
ExternalLink,
9+
Link2,
10+
Loader2,
11+
Video,
12+
} from 'lucide-react';
13+
14+
import { Badge } from '@/components/ui/badge';
15+
import { Checkbox } from '@/components/ui/checkbox';
16+
import { Textarea } from '@/components/ui/textarea';
17+
import { BoundlessButton } from '@/components/buttons';
18+
import EmptyState from '@/components/EmptyState';
19+
import {
20+
useBountyApplications,
21+
useDeclineApplication,
22+
useSelectApplication,
23+
useShortlistApplications,
24+
type BountyOperateOverview,
25+
type OrganizerApplication,
26+
} from '@/features/bounties';
27+
28+
const STATUS_CLASS: Record<string, string> = {
29+
SUBMITTED: 'border-blue-500/30 bg-blue-500/10 text-blue-400',
30+
SHORTLISTED: 'border-amber-500/30 bg-amber-500/10 text-amber-400',
31+
SELECTED: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
32+
DECLINED: 'border-red-500/30 bg-red-500/10 text-red-400',
33+
WITHDRAWN: 'border-zinc-700 bg-zinc-800/60 text-zinc-300',
34+
};
35+
36+
const STATUS_LABEL: Record<string, string> = {
37+
SUBMITTED: 'Submitted',
38+
SHORTLISTED: 'Shortlisted',
39+
SELECTED: 'Selected',
40+
DECLINED: 'Declined',
41+
WITHDRAWN: 'Withdrawn',
42+
};
43+
44+
function shortAddr(a: string): string {
45+
return a.length > 12 ? `${a.slice(0, 5)}${a.slice(-4)}` : a;
46+
}
47+
48+
export default function BountyApplicationsPanel({
49+
organizationId,
50+
bountyId,
51+
overview,
52+
}: {
53+
organizationId: string;
54+
bountyId: string;
55+
overview: BountyOperateOverview;
56+
}) {
57+
const isCompetition = overview.claimType === 'COMPETITION';
58+
59+
const { data, isLoading, error } = useBountyApplications(
60+
organizationId,
61+
bountyId
62+
);
63+
const select = useSelectApplication(organizationId, bountyId);
64+
const shortlist = useShortlistApplications(organizationId, bountyId);
65+
const decline = useDeclineApplication(organizationId, bountyId);
66+
67+
const [checked, setChecked] = useState<Set<string>>(new Set());
68+
69+
if (isLoading) {
70+
return (
71+
<div className='flex items-center justify-center py-16'>
72+
<Loader2 className='h-5 w-5 animate-spin text-zinc-500' />
73+
</div>
74+
);
75+
}
76+
77+
if (error) {
78+
return (
79+
<div className='py-12'>
80+
<EmptyState
81+
title="Couldn't load applications"
82+
description='Try again in a moment.'
83+
type='compact'
84+
/>
85+
</div>
86+
);
87+
}
88+
89+
const applications = data ?? [];
90+
91+
if (applications.length === 0) {
92+
return (
93+
<div className='py-12'>
94+
<EmptyState
95+
title='No applications yet'
96+
description='Applications to this bounty will appear here to review.'
97+
type='compact'
98+
/>
99+
</div>
100+
);
101+
}
102+
103+
const actionable = (a: OrganizerApplication) =>
104+
a.applicationStatus === 'SUBMITTED';
105+
const busy = select.isPending || shortlist.isPending || decline.isPending;
106+
107+
const toggleCheck = (id: string) =>
108+
setChecked(prev => {
109+
const next = new Set(prev);
110+
if (next.has(id)) next.delete(id);
111+
else next.add(id);
112+
return next;
113+
});
114+
115+
const onSelect = (a: OrganizerApplication) =>
116+
select.mutate(
117+
{ applicationId: a.id },
118+
{
119+
onSuccess: () => toast.success('Applicant selected.'),
120+
onError: e => toast.error((e as Error).message || 'Failed to select.'),
121+
}
122+
);
123+
124+
const onShortlist = () => {
125+
const applicationIds = [...checked];
126+
if (
127+
overview.shortlistSize != null &&
128+
applicationIds.length > overview.shortlistSize
129+
) {
130+
toast.error(`Shortlist can hold at most ${overview.shortlistSize}.`);
131+
return;
132+
}
133+
shortlist.mutate(
134+
{ applicationIds },
135+
{
136+
onSuccess: () => {
137+
toast.success('Shortlist approved.');
138+
setChecked(new Set());
139+
},
140+
onError: e =>
141+
toast.error((e as Error).message || 'Failed to shortlist.'),
142+
}
143+
);
144+
};
145+
146+
return (
147+
<div className='space-y-4'>
148+
{applications.map(a => (
149+
<ApplicationCard
150+
key={a.id}
151+
app={a}
152+
isCompetition={isCompetition}
153+
actionable={actionable(a)}
154+
checked={checked.has(a.id)}
155+
onToggleCheck={() => toggleCheck(a.id)}
156+
onSelect={() => onSelect(a)}
157+
onDecline={reason =>
158+
decline.mutate(
159+
{ appId: a.id, body: { reason } },
160+
{
161+
onSuccess: () => toast.success('Application declined.'),
162+
onError: e =>
163+
toast.error((e as Error).message || 'Failed to decline.'),
164+
}
165+
)
166+
}
167+
busy={busy}
168+
/>
169+
))}
170+
171+
{isCompetition && (
172+
<div className='sticky bottom-4 flex items-center justify-between gap-3 rounded-2xl border border-zinc-800 bg-zinc-950/90 p-4 backdrop-blur'>
173+
<span className='text-sm text-zinc-400'>
174+
{checked.size} selected
175+
{overview.shortlistSize != null
176+
? ` (max ${overview.shortlistSize})`
177+
: ''}
178+
</span>
179+
<BoundlessButton
180+
disabled={checked.size === 0 || busy}
181+
onClick={onShortlist}
182+
>
183+
{shortlist.isPending ? (
184+
<Loader2 className='h-4 w-4 animate-spin' />
185+
) : (
186+
`Approve shortlist (${checked.size})`
187+
)}
188+
</BoundlessButton>
189+
</div>
190+
)}
191+
</div>
192+
);
193+
}
194+
195+
function ApplicationCard({
196+
app: a,
197+
isCompetition,
198+
actionable,
199+
checked,
200+
onToggleCheck,
201+
onSelect,
202+
onDecline,
203+
busy,
204+
}: {
205+
app: OrganizerApplication;
206+
isCompetition: boolean;
207+
actionable: boolean;
208+
checked: boolean;
209+
onToggleCheck: () => void;
210+
onSelect: () => void;
211+
onDecline: (reason: string) => void;
212+
busy: boolean;
213+
}) {
214+
const [declining, setDeclining] = useState(false);
215+
const [reason, setReason] = useState('');
216+
const status = a.applicationStatus ?? 'SUBMITTED';
217+
218+
return (
219+
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5'>
220+
<div className='flex items-start justify-between gap-3'>
221+
<div className='flex items-center gap-3'>
222+
{isCompetition && actionable && (
223+
<Checkbox checked={checked} onCheckedChange={onToggleCheck} />
224+
)}
225+
<div>
226+
<p className='font-mono text-sm text-white'>
227+
{shortAddr(a.applicantAddress)}
228+
</p>
229+
<p className='text-xs text-zinc-500'>
230+
Applied {new Date(a.createdAt).toLocaleDateString()}
231+
</p>
232+
</div>
233+
</div>
234+
<Badge
235+
variant='outline'
236+
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
237+
STATUS_CLASS[status] ??
238+
'border-zinc-700 bg-zinc-800/60 text-zinc-300'
239+
}`}
240+
>
241+
{STATUS_LABEL[status] ?? status}
242+
</Badge>
243+
</div>
244+
245+
{/* Proposal */}
246+
{(a.proposalShort || a.proposalFull) && (
247+
<p className='mt-3 text-sm whitespace-pre-wrap text-zinc-300'>
248+
{a.proposalFull || a.proposalShort}
249+
</p>
250+
)}
251+
{a.qualifications && (
252+
<div className='mt-3'>
253+
<p className='text-xs font-medium text-zinc-500'>Qualifications</p>
254+
<p className='text-sm text-zinc-300'>{a.qualifications}</p>
255+
</div>
256+
)}
257+
258+
{/* Meta */}
259+
<div className='mt-3 flex flex-wrap items-center gap-3 text-xs text-zinc-400'>
260+
{a.estimatedDays != null && (
261+
<span className='inline-flex items-center gap-1'>
262+
<Calendar className='h-3.5 w-3.5' />
263+
{a.estimatedDays} days
264+
</span>
265+
)}
266+
{a.videoIntroUrl && (
267+
<a
268+
href={a.videoIntroUrl}
269+
target='_blank'
270+
rel='noreferrer'
271+
className='text-primary inline-flex items-center gap-1 hover:underline'
272+
>
273+
<Video className='h-3.5 w-3.5' />
274+
Video intro
275+
<ExternalLink className='h-3 w-3' />
276+
</a>
277+
)}
278+
</div>
279+
{a.portfolioLinks.length > 0 && (
280+
<div className='mt-2 flex flex-wrap gap-2'>
281+
{a.portfolioLinks.map(url => (
282+
<a
283+
key={url}
284+
href={url}
285+
target='_blank'
286+
rel='noreferrer'
287+
className='inline-flex items-center gap-1 rounded-lg border border-zinc-800 bg-zinc-900/50 px-2.5 py-1 text-xs text-zinc-300 hover:border-zinc-700'
288+
>
289+
<Link2 className='h-3 w-3' />
290+
Portfolio
291+
<ExternalLink className='h-3 w-3 opacity-60' />
292+
</a>
293+
))}
294+
</div>
295+
)}
296+
297+
{a.declineReason && status === 'DECLINED' && (
298+
<p className='mt-3 text-xs text-red-400'>Reason: {a.declineReason}</p>
299+
)}
300+
301+
{/* Actions */}
302+
{actionable &&
303+
(declining ? (
304+
<div className='mt-4 space-y-2 border-t border-zinc-800 pt-4'>
305+
<Textarea
306+
value={reason}
307+
onChange={e => setReason(e.target.value)}
308+
placeholder='Optional reason for the applicant…'
309+
className='min-h-[70px] border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600'
310+
/>
311+
<div className='flex justify-end gap-2'>
312+
<BoundlessButton
313+
variant='outline'
314+
size='sm'
315+
onClick={() => setDeclining(false)}
316+
disabled={busy}
317+
>
318+
Cancel
319+
</BoundlessButton>
320+
<BoundlessButton
321+
size='sm'
322+
className='bg-red-500 text-white hover:bg-red-600'
323+
onClick={() => onDecline(reason.trim())}
324+
disabled={busy}
325+
>
326+
Confirm decline
327+
</BoundlessButton>
328+
</div>
329+
</div>
330+
) : (
331+
<div className='mt-4 flex items-center justify-end gap-2 border-t border-zinc-800 pt-4'>
332+
<BoundlessButton
333+
variant='outline'
334+
size='sm'
335+
className='text-red-400 hover:bg-red-500/10 hover:text-red-300'
336+
onClick={() => setDeclining(true)}
337+
disabled={busy}
338+
>
339+
Decline
340+
</BoundlessButton>
341+
{!isCompetition && (
342+
<BoundlessButton size='sm' onClick={onSelect} disabled={busy}>
343+
<CheckCircle2 className='mr-1.5 h-3.5 w-3.5' />
344+
Select
345+
</BoundlessButton>
346+
)}
347+
</div>
348+
))}
349+
</div>
350+
);
351+
}

components/organization/bounties/manage/BountyManagementDashboard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { ordinal } from '@/lib/utils';
2727
import BountySubmissionsPanel from './BountySubmissionsPanel';
2828
import BountyPayoutPanel from './BountyPayoutPanel';
29+
import BountyApplicationsPanel from './BountyApplicationsPanel';
2930

3031
export default function BountyManagementDashboard() {
3132
const params = useParams<{ id: string; bountyId: string }>();
@@ -157,7 +158,11 @@ export default function BountyManagementDashboard() {
157158
</TabsContent>
158159
{isApplication && (
159160
<TabsContent value='applications'>
160-
<TabPlaceholder title='Applications review' issue='#631' />
161+
<BountyApplicationsPanel
162+
organizationId={organizationId}
163+
bountyId={bountyId}
164+
overview={overview}
165+
/>
161166
</TabsContent>
162167
)}
163168
<TabsContent value='submissions'>

features/bounties/api/keys.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,16 @@ export const bountyKeys = {
4444
organizationId,
4545
bountyId,
4646
] as const,
47+
orgApplications: (
48+
organizationId: string,
49+
bountyId: string,
50+
params: Record<string, unknown> = {}
51+
) =>
52+
[
53+
...bountyKeys.all,
54+
'org-applications',
55+
organizationId,
56+
bountyId,
57+
params,
58+
] as const,
4759
};

0 commit comments

Comments
 (0)