diff --git a/components/organization/bounties/manage/BountyApplicationsPanel.tsx b/components/organization/bounties/manage/BountyApplicationsPanel.tsx new file mode 100644 index 00000000..e7229a8e --- /dev/null +++ b/components/organization/bounties/manage/BountyApplicationsPanel.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { + Calendar, + CheckCircle2, + ExternalLink, + Link2, + Loader2, + Video, +} from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Textarea } from '@/components/ui/textarea'; +import { BoundlessButton } from '@/components/buttons'; +import EmptyState from '@/components/EmptyState'; +import { + useBountyApplications, + useDeclineApplication, + useSelectApplication, + useShortlistApplications, + type BountyOperateOverview, + type OrganizerApplication, +} from '@/features/bounties'; + +const STATUS_CLASS: Record = { + SUBMITTED: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + SHORTLISTED: 'border-amber-500/30 bg-amber-500/10 text-amber-400', + SELECTED: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + DECLINED: 'border-red-500/30 bg-red-500/10 text-red-400', + WITHDRAWN: 'border-zinc-700 bg-zinc-800/60 text-zinc-300', +}; + +const STATUS_LABEL: Record = { + SUBMITTED: 'Submitted', + SHORTLISTED: 'Shortlisted', + SELECTED: 'Selected', + DECLINED: 'Declined', + WITHDRAWN: 'Withdrawn', +}; + +function shortAddr(a: string): string { + return a.length > 12 ? `${a.slice(0, 5)}…${a.slice(-4)}` : a; +} + +export default function BountyApplicationsPanel({ + organizationId, + bountyId, + overview, +}: { + organizationId: string; + bountyId: string; + overview: BountyOperateOverview; +}) { + const isCompetition = overview.claimType === 'COMPETITION'; + + const { data, isLoading, error } = useBountyApplications( + organizationId, + bountyId + ); + const select = useSelectApplication(organizationId, bountyId); + const shortlist = useShortlistApplications(organizationId, bountyId); + const decline = useDeclineApplication(organizationId, bountyId); + + const [checked, setChecked] = useState>(new Set()); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + const applications = data ?? []; + + if (applications.length === 0) { + return ( +
+ +
+ ); + } + + const actionable = (a: OrganizerApplication) => + a.applicationStatus === 'SUBMITTED'; + const busy = select.isPending || shortlist.isPending || decline.isPending; + + const toggleCheck = (id: string) => + setChecked(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const onSelect = (a: OrganizerApplication) => + select.mutate( + { applicationId: a.id }, + { + onSuccess: () => toast.success('Applicant selected.'), + onError: e => toast.error((e as Error).message || 'Failed to select.'), + } + ); + + const onShortlist = () => { + const applicationIds = [...checked]; + if ( + overview.shortlistSize != null && + applicationIds.length > overview.shortlistSize + ) { + toast.error(`Shortlist can hold at most ${overview.shortlistSize}.`); + return; + } + shortlist.mutate( + { applicationIds }, + { + onSuccess: () => { + toast.success('Shortlist approved.'); + setChecked(new Set()); + }, + onError: e => + toast.error((e as Error).message || 'Failed to shortlist.'), + } + ); + }; + + return ( +
+ {applications.map(a => ( + toggleCheck(a.id)} + onSelect={() => onSelect(a)} + onDecline={reason => + decline.mutate( + { appId: a.id, body: { reason } }, + { + onSuccess: () => toast.success('Application declined.'), + onError: e => + toast.error((e as Error).message || 'Failed to decline.'), + } + ) + } + busy={busy} + /> + ))} + + {isCompetition && ( +
+ + {checked.size} selected + {overview.shortlistSize != null + ? ` (max ${overview.shortlistSize})` + : ''} + + + {shortlist.isPending ? ( + + ) : ( + `Approve shortlist (${checked.size})` + )} + +
+ )} +
+ ); +} + +function ApplicationCard({ + app: a, + isCompetition, + actionable, + checked, + onToggleCheck, + onSelect, + onDecline, + busy, +}: { + app: OrganizerApplication; + isCompetition: boolean; + actionable: boolean; + checked: boolean; + onToggleCheck: () => void; + onSelect: () => void; + onDecline: (reason: string) => void; + busy: boolean; +}) { + const [declining, setDeclining] = useState(false); + const [reason, setReason] = useState(''); + const status = a.applicationStatus ?? 'SUBMITTED'; + + return ( +
+
+
+ {isCompetition && actionable && ( + + )} +
+

+ {shortAddr(a.applicantAddress)} +

+

+ Applied {new Date(a.createdAt).toLocaleDateString()} +

+
+
+ + {STATUS_LABEL[status] ?? status} + +
+ + {/* Proposal */} + {(a.proposalShort || a.proposalFull) && ( +

+ {a.proposalFull || a.proposalShort} +

+ )} + {a.qualifications && ( +
+

Qualifications

+

{a.qualifications}

+
+ )} + + {/* Meta */} +
+ {a.estimatedDays != null && ( + + + {a.estimatedDays} days + + )} + {a.videoIntroUrl && ( + + + )} +
+ {a.portfolioLinks.length > 0 && ( +
+ {a.portfolioLinks.map(url => ( + + + Portfolio + + + ))} +
+ )} + + {a.declineReason && status === 'DECLINED' && ( +

Reason: {a.declineReason}

+ )} + + {/* Actions */} + {actionable && + (declining ? ( +
+