diff --git a/app/(landing)/organizations/[id]/bounties/[bountyId]/applications/page.tsx b/app/(landing)/organizations/[id]/bounties/[bountyId]/applications/page.tsx new file mode 100644 index 000000000..79f41d5cb --- /dev/null +++ b/app/(landing)/organizations/[id]/bounties/[bountyId]/applications/page.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import { + Loader2, + AlertCircle, + Search, + CheckCircle2, + XCircle, + Star, + Clock, + User, +} from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { AuthGuard } from '@/components/auth'; +import Loading from '@/components/Loading'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { toast } from 'sonner'; +import { + useBountyApplications, +} from '@/hooks/use-bounty'; +import { + updateApplicationStatus, + type BountyApplication, + type ApplicationStatus, +} from '@/lib/api/bounties'; +import { reportError } from '@/lib/error-reporting'; + +const STATUS_CONFIG: Record< + ApplicationStatus, + { label: string; className: string } +> = { + PENDING: { label: 'Pending', className: 'bg-zinc-700/40 text-zinc-300' }, + SHORTLISTED: { label: 'Shortlisted', className: 'bg-blue-500/20 text-blue-400' }, + SELECTED: { label: 'Selected', className: 'bg-green-500/20 text-green-400' }, + DECLINED: { label: 'Declined', className: 'bg-red-500/20 text-red-400' }, +}; + +function ApplicationCard({ + application, + onStatusChange, + isUpdating, +}: { + application: BountyApplication; + onStatusChange: (id: string, status: ApplicationStatus) => Promise; + isUpdating: boolean; +}) { + const cfg = STATUS_CONFIG[application.status]; + + return ( +
+
+
+
+ +
+
+

+ {application.userName} +

+

+ {new Date(application.submittedAt).toLocaleDateString()} +

+
+
+ + {cfg.label} + +
+ +

{application.proposal}

+ +
+ {application.status !== 'SHORTLISTED' && ( + + )} + {application.status !== 'SELECTED' && ( + + )} + {application.status !== 'DECLINED' && ( + + )} +
+
+ ); +} + +export default function ApplicationsPage() { + const params = useParams(); + const organizationId = params.id as string; + const bountyId = params.bountyId as string; + + const { applications, setApplications, loading, error, total, refetch } = + useBountyApplications({ organizationId, bountyId }); + + const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState('ALL'); + const [updatingId, setUpdatingId] = useState(null); + + const handleStatusChange = useCallback( + async (id: string, status: ApplicationStatus) => { + setUpdatingId(id); + try { + const res = await updateApplicationStatus( + organizationId, + bountyId, + id, + status + ); + if (res.success && res.data) { + setApplications(prev => + prev.map(a => (a.id === id ? { ...a, status } : a)) + ); + toast.success(`Application ${status.toLowerCase()}`); + } else { + toast.error(res.message || 'Failed to update application'); + } + } catch (err) { + reportError(err, { context: 'applications-updateStatus', id }); + toast.error('Failed to update application status'); + } finally { + setUpdatingId(null); + } + }, + [organizationId, bountyId, setApplications] + ); + + const filtered = applications.filter(a => { + const matchesTab = activeTab === 'ALL' || a.status === activeTab; + const matchesSearch = + !search || + a.userName.toLowerCase().includes(search.toLowerCase()) || + a.proposal.toLowerCase().includes(search.toLowerCase()); + return matchesTab && matchesSearch; + }); + + const counts: Record = { + ALL: applications.length, + PENDING: applications.filter(a => a.status === 'PENDING').length, + SHORTLISTED: applications.filter(a => a.status === 'SHORTLISTED').length, + SELECTED: applications.filter(a => a.status === 'SELECTED').length, + DECLINED: applications.filter(a => a.status === 'DECLINED').length, + }; + + return ( + }> +
+ {/* Header */} +
+
+

+ Applications +

+

+ Review, shortlist, and select applicants for this bounty +

+
+
+ +
+ {/* Stats row */} +
+ {( + [ + { key: 'PENDING', label: 'Pending', icon: Clock }, + { key: 'SHORTLISTED', label: 'Shortlisted', icon: Star }, + { key: 'SELECTED', label: 'Selected', icon: CheckCircle2 }, + { key: 'DECLINED', label: 'Declined', icon: XCircle }, + ] as const + ).map(({ key, label, icon: Icon }) => ( +
+
+ + {label} +
+

+ {loading ? '—' : counts[key]} +

+
+ ))} +
+ + {error && ( + + + Error + {error} + + )} + + {/* Search */} +
+ + setSearch(e.target.value)} + className='pl-9 bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500' + /> +
+ + {/* Tabs */} + setActiveTab(v as ApplicationStatus | 'ALL')} + > + + {(['ALL', 'PENDING', 'SHORTLISTED', 'SELECTED', 'DECLINED'] as const).map( + tab => ( + + {tab === 'ALL' ? 'All' : STATUS_CONFIG[tab].label} + + {counts[tab]} + + + ) + )} + + + + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ +

No applications found

+
+ ) : ( +
+ {filtered.map(app => ( + + ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/app/(landing)/organizations/[id]/bounties/[bountyId]/cancel/page.tsx b/app/(landing)/organizations/[id]/bounties/[bountyId]/cancel/page.tsx new file mode 100644 index 000000000..a734779ab --- /dev/null +++ b/app/(landing)/organizations/[id]/bounties/[bountyId]/cancel/page.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + AlertCircle, + XCircle, + Loader2, + ArrowLeft, +} from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { AuthGuard } from '@/components/auth'; +import Loading from '@/components/Loading'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useBounty } from '@/hooks/use-bounty'; +import { cancelBounty } from '@/lib/api/bounties'; +import { reportError } from '@/lib/error-reporting'; + +export default function CancelPage() { + const params = useParams(); + const router = useRouter(); + const organizationId = params.id as string; + const bountyId = params.bountyId as string; + + const { bounty, loading } = useBounty({ organizationId, bountyId }); + const [confirmOpen, setConfirmOpen] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [txHash, setTxHash] = useState(null); + + const canCancel = + bounty && + !['COMPLETED', 'CANCELLED'].includes(bounty.status); + + const handleCancel = async () => { + setCancelling(true); + try { + const res = await cancelBounty(organizationId, bountyId); + if (res.success) { + setTxHash(res.data?.txHash ?? null); + toast.success('Bounty cancelled — escrow funds will be refunded'); + router.push(`/organizations/${organizationId}/bounties/${bountyId}`); + } else { + toast.error(res.message || 'Failed to cancel bounty'); + } + } catch (err) { + reportError(err, { context: 'cancel-bounty', bountyId }); + toast.error('Failed to cancel bounty'); + } finally { + setCancelling(false); + setConfirmOpen(false); + } + }; + + return ( + }> +
+ {/* Header */} +
+
+

+ Cancel Bounty +

+

+ Terminate this bounty and refund escrowed funds +

+
+
+ +
+ {loading ? ( +
+ +
+ ) : !bounty ? null : !canCancel ? ( + + + Cannot cancel + + This bounty is already{' '} + {bounty.status.toLowerCase()}{' '} + and cannot be cancelled. + + + ) : ( +
+ + + This action is irreversible + + Cancelling will call the cancel function on the + escrow contract. All locked funds will be returned to the + organizer wallet. Any active applications or submissions will + be closed. + + + +
+

+ Bounty +

+

{bounty.title}

+

+ Status:{' '} + + {bounty.status.toLowerCase()} + +

+

+ Reward:{' '} + + {bounty.rewardAmount} {bounty.rewardToken} + +

+
+ +
+ + +
+
+ )} +
+ + + + + + Confirm cancellation + + + Are you sure you want to cancel {bounty?.title}? + Escrowed funds will be returned to your wallet. This cannot be undone. + + + + + Keep bounty + + + {cancelling ? ( + <> + + Cancelling… + + ) : ( + 'Yes, cancel & refund' + )} + + + + +
+
+ ); +} diff --git a/app/(landing)/organizations/[id]/bounties/[bountyId]/disputes/page.tsx b/app/(landing)/organizations/[id]/bounties/[bountyId]/disputes/page.tsx new file mode 100644 index 000000000..fa285d761 --- /dev/null +++ b/app/(landing)/organizations/[id]/bounties/[bountyId]/disputes/page.tsx @@ -0,0 +1,302 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import { + Loader2, + AlertCircle, + ShieldAlert, + CheckCircle2, + XCircle, + MessageSquare, +} from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { AuthGuard } from '@/components/auth'; +import Loading from '@/components/Loading'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { useEffect } from 'react'; +import { + getBountyDisputes, + resolveDispute, + type BountyDispute, + type DisputeStatus, +} from '@/lib/api/bounties'; +import { reportError } from '@/lib/error-reporting'; + +const STATUS_CONFIG: Record = { + OPEN: { label: 'Open', className: 'bg-orange-500/20 text-orange-400' }, + UNDER_REVIEW: { + label: 'Under Review', + className: 'bg-blue-500/20 text-blue-400', + }, + RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400' }, + DISMISSED: { label: 'Dismissed', className: 'bg-zinc-700/40 text-zinc-400' }, +}; + +function DisputeCard({ + dispute, + onResolveClick, +}: { + dispute: BountyDispute; + onResolveClick: (id: string) => void; +}) { + const cfg = STATUS_CONFIG[dispute.status]; + const isActionable = + dispute.status === 'OPEN' || dispute.status === 'UNDER_REVIEW'; + + return ( +
+
+
+

+ Raised by {dispute.raisedByUserName} +

+

+ {new Date(dispute.createdAt).toLocaleDateString()} +

+
+ + {cfg.label} + +
+ +

{dispute.description}

+ + {dispute.resolution && ( +
+

+ Resolution +

+

{dispute.resolution}

+
+ )} + + {isActionable && ( + + )} +
+ ); +} + +export default function DisputesPage() { + const params = useParams(); + const organizationId = params.id as string; + const bountyId = params.bountyId as string; + + const [disputes, setDisputes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [resolveDialogId, setResolveDialogId] = useState(null); + const [resolution, setResolution] = useState(''); + const [resolving, setResolving] = useState(false); + + const fetchDisputes = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await getBountyDisputes(organizationId, bountyId); + if (res.success && res.data) { + setDisputes(res.data); + } else { + setError(res.message || 'Failed to load disputes'); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to load disputes'; + setError(msg); + reportError(err, { context: 'disputes-fetch', bountyId }); + } finally { + setLoading(false); + } + }, [organizationId, bountyId]); + + useEffect(() => { + fetchDisputes(); + }, [fetchDisputes]); + + const handleResolve = async () => { + if (!resolveDialogId || !resolution.trim()) return; + setResolving(true); + try { + const res = await resolveDispute( + organizationId, + bountyId, + resolveDialogId, + resolution.trim() + ); + if (res.success && res.data) { + setDisputes(prev => + prev.map(d => (d.id === resolveDialogId ? res.data! : d)) + ); + toast.success('Resolution added'); + setResolveDialogId(null); + setResolution(''); + } else { + toast.error(res.message || 'Failed to add resolution'); + } + } catch (err) { + reportError(err, { context: 'disputes-resolve', resolveDialogId }); + toast.error('Failed to add resolution'); + } finally { + setResolving(false); + } + }; + + const openCount = disputes.filter(d => d.status === 'OPEN').length; + const underReviewCount = disputes.filter( + d => d.status === 'UNDER_REVIEW' + ).length; + const resolvedCount = disputes.filter(d => d.status === 'RESOLVED').length; + + return ( + }> +
+ {/* Header */} +
+
+

+ Disputes +

+

+ Review and resolve disputes raised by participants +

+
+
+ +
+ {/* Stats */} +
+
+
+ + Open +
+

+ {loading ? '—' : openCount} +

+
+
+
+ + Under Review +
+

+ {loading ? '—' : underReviewCount} +

+
+
+
+ + Resolved +
+

+ {loading ? '—' : resolvedCount} +

+
+
+ + {error && ( + + + Error + {error} + + )} + + {loading ? ( +
+ +
+ ) : disputes.length === 0 ? ( +
+ +

No disputes raised

+
+ ) : ( +
+ {disputes.map(d => ( + + ))} +
+ )} +
+ + { + if (!open) { + setResolveDialogId(null); + setResolution(''); + } + }} + > + + + Add resolution + + Describe how this dispute has been resolved. This will be visible + to the participant who raised it. + + +