From dfe924a123cfebac57684d33b176804875088fb3 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 6 Jul 2026 01:22:25 +0100 Subject: [PATCH] feat(bounty): mirror hackathon organizer shell for bounties Bring the bounty organizer surface in line with the hackathon one: - Persistent management sidebar (replaces the general org sidebar on bounty management routes) with a bounty switcher dropdown and section nav; wired via the organizations layout. - Overview becomes the analytics dashboard: intake stat cards, trend chart cards, details/prize tiers, and a lifecycle timeline. - Standalone settings page with a bounty switcher sidebar and tabs. General tab is an editable form covering every off-chain Configure field (scope + submission/application controls); rewards, prize tiers and the escrow deadline stay read-only (on-chain). Cancel/refund + archive move here as Close-out. - Organizer cards mirror the hackathon card: hover Settings + Remove actions and submission count. Remove opens a confirmation modal that archives a closed-out bounty or routes to cancel/refund for a live one. - Cancel buttons rendered solid red. --- .../bounties/[bountyId]/settings/page.tsx | 288 ++++++++++++ .../organizations/[id]/bounties/page.tsx | 120 +++-- app/(landing)/organizations/layout.tsx | 18 +- .../bounties/RemoveBountyDialog.tsx | 111 +++++ .../bounties/manage/BountyManageSidebar.tsx | 272 +++++++++++ .../manage/BountyManagementDashboard.tsx | 210 +-------- .../bounties/manage/BountyOverviewPanel.tsx | 329 ++++++++++++++ .../bounties/manage/BountySelector.tsx | 117 +++++ .../bounties/manage/BountySettingsPanel.tsx | 3 +- .../settings/BountyGeneralSettingsTab.tsx | 428 ++++++++++++++++++ 10 files changed, 1649 insertions(+), 247 deletions(-) create mode 100644 app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx create mode 100644 components/organization/bounties/RemoveBountyDialog.tsx create mode 100644 components/organization/bounties/manage/BountyManageSidebar.tsx create mode 100644 components/organization/bounties/manage/BountyOverviewPanel.tsx create mode 100644 components/organization/bounties/manage/BountySelector.tsx create mode 100644 components/organization/bounties/settings/BountyGeneralSettingsTab.tsx diff --git a/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx b/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx new file mode 100644 index 00000000..13688d73 --- /dev/null +++ b/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { Suspense } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { + ArrowLeft, + CalendarClock, + Info, + Loader2, + Lock, + Settings, + ShieldAlert, + Trophy, +} from 'lucide-react'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { AuthGuard } from '@/components/auth'; +import Loading from '@/components/Loading'; +import EmptyState from '@/components/EmptyState'; +import { DueCountdown } from '@/components/bounties/DueCountdown'; +import BountyGeneralSettingsTab from '@/components/organization/bounties/settings/BountyGeneralSettingsTab'; +import BountySettingsPanel from '@/components/organization/bounties/manage/BountySettingsPanel'; +import { + useBountyOverview, + type BountyOperateOverview, +} from '@/features/bounties'; +import { ordinal } from '@/lib/utils'; + +const tabTriggerClassName = + 'data-[state=active]:border-b-primary rounded-none border-b-2 border-transparent bg-transparent px-0 pt-4 pb-3 text-sm font-medium text-zinc-400 transition-all data-[state=active]:text-white data-[state=active]:shadow-none flex items-center gap-2'; + +const SETTINGS_SECTIONS = new Set([ + 'general', + 'rewards', + 'timeline', + 'closeout', +]); + +export default function BountySettingsPage() { + return ( + }> + + + ); +} + +function BountySettingsView() { + const params = useParams<{ id: string; bountyId: string }>(); + const searchParams = useSearchParams(); + const organizationId = params?.id ?? ''; + const bountyId = params?.bountyId ?? ''; + const requestedSection = searchParams?.get('section') ?? ''; + const initialSection = SETTINGS_SECTIONS.has(requestedSection) + ? requestedSection + : 'general'; + + const { + data: overview, + isLoading, + error, + } = useBountyOverview(organizationId, bountyId); + + return ( + }> +
+
+ + + Back to dashboard + + +
+ +
+

+ Bounty Settings +

+

+ Configuration, rewards, timeline, and close-out. +

+
+
+ + {isLoading ? ( +
+ +
+ ) : error || !overview ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function BountySettingsContent({ + organizationId, + bountyId, + overview, + defaultSection, +}: { + organizationId: string; + bountyId: string; + overview: BountyOperateOverview; + defaultSection: string; +}) { + return ( + +
+ + + + General + + + + Rewards + + + + Timeline + + + + Close-out + + +
+ + {/* General — editable off-chain details. */} + + + + + {/* Rewards — on-chain, immutable once funded. */} + +
+ +
+

+ Reward pool +

+

+ {overview.rewardAmount.toLocaleString()} {overview.rewardCurrency} +

+ {overview.prizeTiers.length > 0 && ( +
+ {overview.prizeTiers + .slice() + .sort((a, b) => a.position - b.position) + .map(tier => ( +
+ + {ordinal(tier.position)} place + + + {Number(tier.amount).toLocaleString()}{' '} + {overview.rewardCurrency} + +
+ ))} +
+ )} +
+
+
+ + {/* Timeline — on-chain deadline, immutable once funded. */} + +
+ +
+

Timeline

+
+ {overview.submissionDeadline ? ( +
+
Submission deadline
+
+ +
+
+ ) : ( + + )} + {overview.applicationWindowCloseAt && ( +
+
Applications close
+
+ +
+
+ )} + {overview.maxApplicants != null && ( + + )} + {overview.shortlistSize != null && ( + + )} +
+
+
+
+ + {/* Close-out — cancel / refund + archive (moved from the dashboard). */} + + + +
+ ); +} + +function OnChainNotice() { + return ( +
+ + + These values are anchored on-chain in the escrow and cannot be changed + after funding. + +
+ ); +} + +function Row({ + label, + value, + hint, + capitalize, +}: { + label: string; + value: string; + hint?: string | null; + capitalize?: boolean; +}) { + return ( +
+
{label}
+
+ + {value} + + {hint && ( + {hint} + )} +
+
+ ); +} diff --git a/app/(landing)/organizations/[id]/bounties/page.tsx b/app/(landing)/organizations/[id]/bounties/page.tsx index 02332495..416f0d70 100644 --- a/app/(landing)/organizations/[id]/bounties/page.tsx +++ b/app/(landing)/organizations/[id]/bounties/page.tsx @@ -3,12 +3,14 @@ import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { - ArrowRight, Calendar, FileText, Plus, Search, + Settings, Target, Trash2, } from 'lucide-react'; @@ -26,7 +28,9 @@ import { import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; import DeleteBountyDraftDialog from '@/components/organization/bounties/DeleteBountyDraftDialog'; +import RemoveBountyDialog from '@/components/organization/bounties/RemoveBountyDialog'; import { + archiveBounty, useDeleteDraft, useDraftList, useOrganizationBounties, @@ -76,26 +80,6 @@ function statusDisplay(status: string) { ); } -/** - * Status-aware manage CTA: deep-links into the dashboard tab that matches where - * the organizer's attention is needed next. - */ -function manageCta(status: string): { label: string; tab: string } { - switch (status) { - case 'submitted': - case 'under_review': - return { label: 'Review submissions', tab: 'submissions' }; - case 'in_progress': - return { label: 'Review & pay', tab: 'payout' }; - case 'completed': - return { label: 'View results', tab: 'results' }; - case 'cancelled': - return { label: 'View bounty', tab: 'overview' }; - default: - return { label: 'Manage', tab: 'overview' }; - } -} - const draftPrizePool = (draft: BountyDraft): number => (draft.data?.reward?.prizeTiers ?? []).reduce( (sum, tier) => sum + (Number(tier.amount) || 0), @@ -119,6 +103,23 @@ export default function OrganizationBountiesPage() { title: string; } | null>(null); + // Removing a published bounty: archive it (once closed out) or route to the + // cancel/refund flow (while it's still live). Confirmed in RemoveBountyDialog. + const [bountyToRemove, setBountyToRemove] = + useState(null); + const archiveMutation = useMutation({ + mutationFn: (bountyId: string) => archiveBounty(organizationId, bountyId), + onSuccess: () => { + toast.success('Bounty archived.'); + setBountyToRemove(null); + void publishedQuery.refetch(); + }, + onError: err => + toast.error( + err instanceof Error ? err.message : 'Failed to archive the bounty' + ), + }); + const allDrafts: BountyDraft[] = draftsQuery.data ?? []; const published: OrganizationBountyListItem[] = useMemo(() => { @@ -302,30 +303,44 @@ export default function OrganizationBountiesPage() {

reward

- + - {bounty._count?.submissions ?? 0} + {bounty._count?.submissions ?? 0} submissions - {(() => { - const cta = manageCta(bounty.status); - return ( - { - e.stopPropagation(); - router.push( - `/organizations/${organizationId}/bounties/${bounty.id}?tab=${cta.tab}` - ); - }} - > - {cta.label} - - - ); - })()} + + {/* Hover actions (mirrors the hackathon organizer card). */} +
+ + +
); })} @@ -429,6 +444,27 @@ export default function OrganizationBountiesPage() { if (draftToDelete) deleteDraft.mutate(draftToDelete.id); }} /> + + { + if (!open) setBountyToRemove(null); + }} + bountyTitle={bountyToRemove?.title ?? ''} + status={bountyToRemove?.status ?? ''} + isArchiving={archiveMutation.isPending} + onArchive={() => { + if (bountyToRemove) archiveMutation.mutate(bountyToRemove.id); + }} + onCancelRefund={() => { + if (!bountyToRemove) return; + const id = bountyToRemove.id; + setBountyToRemove(null); + router.push( + `/organizations/${organizationId}/bounties/${id}/settings?section=closeout` + ); + }} + /> ); } diff --git a/app/(landing)/organizations/layout.tsx b/app/(landing)/organizations/layout.tsx index 6716491f..869cc4de 100644 --- a/app/(landing)/organizations/layout.tsx +++ b/app/(landing)/organizations/layout.tsx @@ -13,6 +13,7 @@ import { import { cn } from '@/lib/utils'; import HackathonSidebar from '@/components/organization/hackathons/details/HackathonSidebar'; import HackathonNavigationLoader from '@/components/organization/hackathons/details/HackathonNavigationLoader'; +import BountyManageSidebar from '@/components/organization/bounties/manage/BountyManageSidebar'; export default function OrganizationsLayout({ children, @@ -29,6 +30,14 @@ export default function OrganizationsLayout({ // under /hackathons/ except the list page itself. const showHackathonSidebar = pathname.includes('/hackathons/') && !pathname.endsWith('/hackathons'); + // Management shell for a published bounty (dashboard + settings). Excludes the + // list, the create wizard (/new) and draft editing (/drafts), which keep the + // general org sidebar. + const showBountySidebar = + pathname.includes('/bounties/') && + !pathname.endsWith('/bounties') && + !pathname.includes('/bounties/new') && + !pathname.includes('/bounties/drafts'); const getOrgIdFromPath = () => { if (pathname.startsWith('/organizations/')) { const pathParts = pathname.split('/'); @@ -49,6 +58,7 @@ export default function OrganizationsLayout({ showOrganizationSidebar={showOrganizationSidebar} showNewGrantSidebar={showNewGrantSidebar} showHackathonSidebar={showHackathonSidebar} + showBountySidebar={showBountySidebar} initialOrgId={initialOrgId} > {children} @@ -63,12 +73,14 @@ function OrganizationsLayoutContent({ showOrganizationSidebar, showNewGrantSidebar, showHackathonSidebar, + showBountySidebar, initialOrgId, }: { children: React.ReactNode; showOrganizationSidebar: boolean; showNewGrantSidebar: boolean; showHackathonSidebar: boolean; + showBountySidebar: boolean; initialOrgId: string | null; }) { const { isNavigating } = useNavigationLoading(); @@ -81,10 +93,14 @@ function OrganizationsLayoutContent({
{showOrganizationSidebar && !showNewGrantSidebar && - !showHackathonSidebar && } + !showHackathonSidebar && + !showBountySidebar && } {showHackathonSidebar && ( )} + {showBountySidebar && ( + + )} {/* {showNewGrantSidebar && } */}
void; + bountyTitle: string; + /** Lowercase lifecycle status of the bounty being removed. */ + status: string; + isArchiving?: boolean; + /** Archive (soft-delete) a closed-out bounty. */ + onArchive: () => void; + /** Route to the settings close-out (cancel + refund) for a live bounty. */ + onCancelRefund: () => void; +} + +/** + * Confirmation modal for removing a published bounty, mirroring the hackathon + * delete dialog. A funded bounty can't be hard-deleted: once completed or + * cancelled it is archived (recoverable, never deleted); while still live it + * must be cancelled and refunded on-chain, so we route there instead. + */ +export default function RemoveBountyDialog({ + open, + onOpenChange, + bountyTitle, + status, + isArchiving = false, + onArchive, + onCancelRefund, +}: RemoveBountyDialogProps) { + const isTerminal = TERMINAL_STATUSES.has(status); + const title = bountyTitle || 'this bounty'; + + return ( + + + +
+ {isTerminal ? ( + + ) : ( + + )} +
+ + {isTerminal ? 'Archive this bounty?' : 'This bounty is still live'} + + + {isTerminal ? ( + <> + + "{title}" + {' '} + will be hidden from your active list. Its records are never + deleted, and you can restore it anytime. + + ) : ( + <> + + "{title}" + {' '} + is funded on-chain and can't be deleted. To remove it, + cancel and refund it first. + + )} + +
+ + + + {isTerminal ? 'Cancel' : 'Not now'} + + {isTerminal ? ( + + {isArchiving ? 'Archiving...' : 'Archive bounty'} + + ) : ( + + Cancel & refund + + )} + +
+
+ ); +} diff --git a/components/organization/bounties/manage/BountyManageSidebar.tsx b/components/organization/bounties/manage/BountyManageSidebar.tsx new file mode 100644 index 00000000..3d62cd6f --- /dev/null +++ b/components/organization/bounties/manage/BountyManageSidebar.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useMemo } from 'react'; +import Link from 'next/link'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { + BarChartBig, + FileText, + LayoutDashboard, + Menu, + Settings, + Trophy, + Users, + type LucideIcon, +} from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { + useBountyOverview, + useOrganizationBounties, +} from '@/features/bounties'; +import BountySelector, { type SidebarBounty } from './BountySelector'; + +type SectionKey = + | 'overview' + | 'applications' + | 'submissions' + | 'payout' + | 'results' + | 'settings'; + +interface NavItem { + key: SectionKey; + icon: LucideIcon; + label: string; + description: string; + href: string; +} + +const DRAFT_STATUSES = new Set(['draft', 'draft_awaiting_funding']); + +/** + * Persistent management sidebar for a single bounty, mirroring the hackathon + * organizer sidebar: a bounty switcher on top and the operational nav below. + * Rendered by the organizations layout on bounty management routes (replacing + * the general org sidebar), so it is self-contained — it derives the bounty id + * from the path and fetches its own data. + */ +export default function BountyManageSidebar({ + organizationId, +}: { + organizationId?: string; +}) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const orgId = useMemo(() => { + if (organizationId) return organizationId; + const parts = pathname?.split('/') ?? []; + return parts[1] === 'organizations' ? (parts[2] ?? '') : ''; + }, [organizationId, pathname]); + + const bountyId = useMemo(() => { + const parts = pathname?.split('/') ?? []; + // /organizations/{org}/bounties/{bountyId}[/settings] + if (parts[3] === 'bounties') { + const id = parts[4]; + if (id && id !== 'new' && id !== 'drafts') return id; + } + return ''; + }, [pathname]); + + const { data: listData } = useOrganizationBounties(orgId); + const { data: overview } = useBountyOverview(orgId, bountyId); + + const bounties: SidebarBounty[] = useMemo( + () => + (listData ?? []) + .filter(b => !DRAFT_STATUSES.has(b.status)) + .map(b => ({ + id: b.id, + title: b.title ?? 'Untitled bounty', + status: b.status, + })), + [listData] + ); + + const onSettings = pathname?.endsWith('/settings') ?? false; + const activeKey: SectionKey = onSettings + ? 'settings' + : ((searchParams?.get('tab') as SectionKey) ?? 'overview'); + + const isApplication = + overview?.entryType === 'APPLICATION_LIGHT' || + overview?.entryType === 'APPLICATION_FULL'; + const isCompleted = overview?.status === 'completed'; + + const base = `/organizations/${orgId}/bounties/${bountyId}`; + const items: NavItem[] = useMemo(() => { + const list: NavItem[] = [ + { + key: 'overview', + icon: LayoutDashboard, + label: 'Overview', + description: 'Analytics dashboard', + href: `${base}?tab=overview`, + }, + ]; + if (isApplication) { + list.push({ + key: 'applications', + icon: Users, + label: 'Applications', + description: 'Shortlist and select', + href: `${base}?tab=applications`, + }); + } + list.push( + { + key: 'submissions', + icon: FileText, + label: 'Submissions', + description: 'Review submitted work', + href: `${base}?tab=submissions`, + }, + { + key: 'payout', + icon: Trophy, + label: 'Payout & Winners', + description: 'Select and pay winners', + href: `${base}?tab=payout`, + } + ); + if (isCompleted) { + list.push({ + key: 'results', + icon: BarChartBig, + label: 'Results', + description: 'Winners and announcement', + href: `${base}?tab=results`, + }); + } + list.push({ + key: 'settings', + icon: Settings, + label: 'Settings', + description: 'Configure and close out', + href: `${base}/settings`, + }); + return list; + }, [base, isApplication, isCompleted]); + + const content = ( + + ); + + return ( + <> + {/* Mobile: hamburger + sheet */} +
+ + + + + + {content} + + +
+ + {/* Desktop: fixed sidebar */} + + + ); +} + +function BountySidebarContent({ + organizationId, + bountyId, + bounties, + items, + activeKey, +}: { + organizationId: string; + bountyId: string; + bounties: SidebarBounty[]; + items: NavItem[]; + activeKey: SectionKey; +}) { + return ( + + ); +} diff --git a/components/organization/bounties/manage/BountyManagementDashboard.tsx b/components/organization/bounties/manage/BountyManagementDashboard.tsx index bf563bc7..035bfc1d 100644 --- a/components/organization/bounties/manage/BountyManagementDashboard.tsx +++ b/components/organization/bounties/manage/BountyManagementDashboard.tsx @@ -3,31 +3,26 @@ import { useState } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Info, Loader2 } from 'lucide-react'; +import { Info, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import EmptyState from '@/components/EmptyState'; -import { DueCountdown } from '@/components/bounties/DueCountdown'; import { bountyStatusClass } from '@/components/bounties/statusClass'; import { computeBountyModeLabel, computeBountyModeDescription, } from '@/components/organization/bounties/new/tabs/schemas/modeSchema'; -import { - useBountyOverview, - type BountyOperateOverview, -} from '@/features/bounties'; -import { ordinal } from '@/lib/utils'; +import { useBountyOverview } from '@/features/bounties'; +import BountyOverviewPanel from './BountyOverviewPanel'; import BountySubmissionsPanel from './BountySubmissionsPanel'; import BountyPayoutPanel from './BountyPayoutPanel'; import BountyApplicationsPanel from './BountyApplicationsPanel'; -import BountySettingsPanel from './BountySettingsPanel'; import BountyResultsPanel from './BountyResultsPanel'; /** Tabs the org bounty list can deep-link into via `?tab=`. */ @@ -37,7 +32,6 @@ const LINKABLE_TABS = new Set([ 'submissions', 'payout', 'results', - 'settings', ]); export default function BountyManagementDashboard() { @@ -119,14 +113,6 @@ export default function BountyManagementDashboard() { return (
- - - Back to bounties - - {/* Header */}
@@ -165,20 +151,10 @@ export default function BountyManagementDashboard() {
- - - Overview - {isApplication && ( - Applications - )} - Submissions - Payout & Winners - {isCompleted && Results} - Settings - - + {/* Sections are driven by the management sidebar via ?tab=. */} + - + {isApplication && ( @@ -215,179 +191,7 @@ export default function BountyManagementDashboard() { /> )} - - -
); } - -function OverviewPanel({ overview }: { overview: BountyOperateOverview }) { - const { intake } = overview; - return ( -
- {/* Intake stats */} -
- - - -
- - {/* Facts */} -
-
-

Details

-
- - {overview.submissionDeadline && ( -
-
Submission deadline
-
- -
-
- )} - {overview.applicationWindowCloseAt && ( -
-
Applications close
-
- -
-
- )} - {overview.maxApplicants != null && ( - - )} - {overview.shortlistSize != null && ( - - )} - {overview.escrowEventId && ( - - )} -
-
- - {/* Prize tiers */} -
-

Prize tiers

- {overview.prizeTiers.length === 0 ? ( -

No prize tiers configured.

- ) : ( -
- {overview.prizeTiers.map(tier => ( -
- - {ordinal(tier.position)} place - - - {Number(tier.amount).toLocaleString()}{' '} - {overview.rewardCurrency} - -
- ))} -
- )} -
-
-
- ); -} - -function StatCard({ - label, - total, - breakdown, -}: { - label: string; - total: number; - breakdown: Array<[string, string | number]>; -}) { - return ( -
-

{label}

-

{total}

-
- {breakdown.map(([k, v]) => ( -
-
{k}
-
{v}
-
- ))} -
-
- ); -} - -function Row({ - label, - value, - mono, -}: { - label: string; - value: string; - mono?: boolean; -}) { - return ( -
-
{label}
-
- {value} -
-
- ); -} diff --git a/components/organization/bounties/manage/BountyOverviewPanel.tsx b/components/organization/bounties/manage/BountyOverviewPanel.tsx new file mode 100644 index 00000000..eb92ab0b --- /dev/null +++ b/components/organization/bounties/manage/BountyOverviewPanel.tsx @@ -0,0 +1,329 @@ +'use client'; + +import { Calendar, Check, LineChart, TrendingUp } from 'lucide-react'; + +import { DueCountdown } from '@/components/bounties/DueCountdown'; +import { ordinal } from '@/lib/utils'; +import type { BountyOperateOverview } from '@/features/bounties'; + +/** + * Overview = the bounty analytics dashboard, mirroring the hackathon organizer + * overview: an Analytics section (intake stat cards + trend charts) followed by + * a Timeline of the bounty lifecycle. Trend charts await a backend analytics + * endpoint, so they render the same empty state the hackathon charts use. + */ +export default function BountyOverviewPanel({ + overview, +}: { + overview: BountyOperateOverview; +}) { + const { intake } = overview; + + return ( +
+ {/* ── Analytics ── */} +
+
+ +

+ Analytics +

+
+ + {/* Intake stat cards */} +
+ + + +
+ + {/* Trend charts */} +
+ + +
+
+ + {/* ── Details + prize tiers ── */} +
+
+

Details

+
+ + {overview.submissionDeadline && ( +
+
Submission deadline
+
+ +
+
+ )} + {overview.applicationWindowCloseAt && ( +
+
Applications close
+
+ +
+
+ )} + {overview.maxApplicants != null && ( + + )} + {overview.shortlistSize != null && ( + + )} + {overview.escrowEventId && ( + + )} +
+
+ +
+

Prize tiers

+ {overview.prizeTiers.length === 0 ? ( +

No prize tiers configured.

+ ) : ( +
+ {overview.prizeTiers.map(tier => ( +
+ + {ordinal(tier.position)} place + + + {Number(tier.amount).toLocaleString()}{' '} + {overview.rewardCurrency} + +
+ ))} +
+ )} +
+
+ + {/* ── Timeline ── */} + +
+ ); +} + +function ChartCard({ title }: { title: string }) { + return ( +
+
+ {title} +
+
+ +

No trend data yet

+
+
+ ); +} + +interface TimelineEvent { + label: string; + description: string; + date?: string | null; + state: 'completed' | 'ongoing' | 'upcoming'; +} + +function BountyTimeline({ overview }: { overview: BountyOperateOverview }) { + const now = Date.now(); + const isTerminal = + overview.status === 'completed' || overview.status === 'cancelled'; + + const dated = (date?: string | null): TimelineEvent['state'] => + date && new Date(date).getTime() <= now ? 'completed' : 'upcoming'; + + const events: TimelineEvent[] = [ + { + label: 'Published', + description: 'Bounty published and funded on-chain.', + date: overview.createdAt, + state: 'completed', + }, + ]; + + if (overview.applicationWindowCloseAt) { + events.push({ + label: 'Applications close', + description: 'The application window closes.', + date: overview.applicationWindowCloseAt, + state: dated(overview.applicationWindowCloseAt), + }); + } + if (overview.submissionDeadline) { + events.push({ + label: 'Submission deadline', + description: 'Work submissions close for review.', + date: overview.submissionDeadline, + state: dated(overview.submissionDeadline), + }); + } + + events.push({ + label: overview.status === 'cancelled' ? 'Cancelled' : 'Completed', + description: + overview.status === 'cancelled' + ? 'Bounty cancelled and escrow refunded.' + : 'Winners selected and rewards paid out.', + state: isTerminal ? 'completed' : 'upcoming', + }); + + return ( +
+
+ +

+ Timeline +

+
+ +
+ {events.map((phase, index) => { + const isLast = index === events.length - 1; + const isCompleted = phase.state === 'completed'; + const isOngoing = phase.state === 'ongoing'; + return ( +
+
+ {isCompleted ? ( +
+ +
+ ) : isOngoing ? ( +
+
+
+ ) : ( +
+ )} + {!isLast && ( +
+
+
+ )} +
+
+
+

+ {phase.label} +

+

+ {phase.description} +

+
+ {phase.date && ( +
+ {new Date(phase.date).toLocaleDateString()} +
+ )} +
+
+ ); + })} +
+
+ ); +} + +function StatCard({ + label, + total, + breakdown, +}: { + label: string; + total: number; + breakdown: Array<[string, string | number]>; +}) { + return ( +
+

{label}

+

{total}

+
+ {breakdown.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ ); +} + +function Row({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/components/organization/bounties/manage/BountySelector.tsx b/components/organization/bounties/manage/BountySelector.tsx new file mode 100644 index 00000000..1fd38eb1 --- /dev/null +++ b/components/organization/bounties/manage/BountySelector.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { Check, ChevronsUpDown, Plus } from 'lucide-react'; +import { useRouter } from 'next/navigation'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; + +export interface SidebarBounty { + id: string; + title: string; + status: string; +} + +/** Lifecycle status -> selector dot color. */ +function statusDot(status: string): string { + switch (status) { + case 'open': + case 'in_progress': + case 'submitted': + case 'under_review': + return 'bg-emerald-500'; + case 'completed': + return 'bg-primary'; + case 'cancelled': + return 'bg-zinc-500'; + default: + return 'bg-amber-500'; + } +} + +/** + * Switches between the organization's bounties from the management sidebar, + * mirroring the hackathon selector. Selecting one navigates to its dashboard. + */ +export default function BountySelector({ + organizationId, + bounties, + currentId, +}: { + organizationId: string; + bounties: SidebarBounty[]; + currentId: string; +}) { + const router = useRouter(); + const current = bounties.find(b => b.id === currentId); + + if (!current) { + return ( +
+
+ Loading… +
+ ); + } + + return ( + + + + + + + {bounties.map(b => ( + + router.push(`/organizations/${organizationId}/bounties/${b.id}`) + } + className='flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 hover:bg-[#252525] focus:bg-[#252525]' + > + +
+ + {b.title || 'Untitled bounty'} + + + {b.status.replace(/_/g, ' ')} + +
+ {b.id === currentId && } +
+ ))} + + + + router.push(`/organizations/${organizationId}/bounties/new`) + } + className='text-primary flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 hover:bg-[#252525] focus:bg-[#252525]' + > + + New bounty + +
+
+ ); +} diff --git a/components/organization/bounties/manage/BountySettingsPanel.tsx b/components/organization/bounties/manage/BountySettingsPanel.tsx index 6cb6be9f..47d20666 100644 --- a/components/organization/bounties/manage/BountySettingsPanel.tsx +++ b/components/organization/bounties/manage/BountySettingsPanel.tsx @@ -143,7 +143,7 @@ export default function BountySettingsPanel({ ) : ( { setConfirmText(''); setConfirmOpen(true); @@ -256,6 +256,7 @@ export default function BountySettingsPanel({ { setConfirmOpen(false); diff --git a/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx b/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx new file mode 100644 index 00000000..7f87f1c7 --- /dev/null +++ b/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx @@ -0,0 +1,428 @@ +'use client'; + +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { BoundlessButton } from '@/components/buttons'; +import { api } from '@/lib/api/api'; +import { bountyKeys, useBounty, type BountyPublic } from '@/features/bounties'; +import { + BOUNTY_CATEGORIES, + CATEGORY_LABELS, + type BountyCategory, +} from '@/components/organization/bounties/new/tabs/schemas/scopeSchema'; + +const generalSchema = z.object({ + title: z + .string() + .trim() + .min(1, 'Title is required') + .max(200, 'Title must be 200 characters or fewer'), + description: z + .string() + .trim() + .min(1, 'Description is required') + .max(5000, 'Description must be 5000 characters or fewer'), + category: z.enum(BOUNTY_CATEGORIES), + submissionVisibility: z.enum(['ORGANIZER_ONLY', 'HIDDEN_UNTIL_DEADLINE']), + documentation: z.boolean(), + tweet: z.boolean(), + demoVideo: z.boolean(), + media: z.boolean(), + reputationMinimum: z.union([z.number().int().min(0), z.nan()]).optional(), + applicationWindowCloseAt: z.string().optional(), + maxApplicants: z.union([z.number().int().min(1), z.nan()]).optional(), + shortlistSize: z.union([z.number().int().min(1), z.nan()]).optional(), +}); + +type GeneralFormValues = z.infer; + +const inputClassName = + 'h-12 w-full rounded-xl border border-zinc-700 bg-zinc-900/80 p-4 text-white placeholder:text-zinc-500 focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/30'; + +const REQUIREMENTS: Array<{ + name: 'documentation' | 'tweet' | 'demoVideo' | 'media'; + label: string; + hint: string; +}> = [ + { + name: 'documentation', + label: 'Documentation', + hint: 'A docs or write-up link is required.', + }, + { name: 'tweet', label: 'Tweet', hint: 'A tweet/X post link is required.' }, + { + name: 'demoVideo', + label: 'Demo video', + hint: 'A demo video link is required.', + }, + { name: 'media', label: 'Media', hint: 'At least one image is required.' }, +]; + +/** ISO date-time -> value for (local time). */ +function toLocalInput(iso?: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Empty / NaN number field -> null; otherwise the number. */ +function numOrNull(v: number | undefined): number | null { + return v == null || Number.isNaN(v) ? null : v; +} + +/** + * Editable off-chain settings for a published bounty, covering every off-chain + * field from the Configure wizard: scope (title / description / category) and + * submission + application controls (visibility, required fields, reputation, + * application window, applicant / shortlist limits). On-chain values (rewards, + * prize tiers, escrow deadline) and the mode are immutable after funding and + * live in the read-only Reward / Timeline tabs. Saving hits the organizer + * off-chain PATCH endpoint (tracked in boundless-nestjs#373). + */ +export default function BountyGeneralSettingsTab({ + organizationId, + bountyId, +}: { + organizationId: string; + bountyId: string; +}) { + const { data: bounty, isLoading } = useBounty(bountyId); + + if (isLoading || !bounty) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + +function GeneralForm({ + organizationId, + bountyId, + bounty, +}: { + organizationId: string; + bountyId: string; + bounty: BountyPublic; +}) { + const queryClient = useQueryClient(); + const [saving, setSaving] = useState(false); + + const isApplication = + bounty.entryType === 'APPLICATION_LIGHT' || + bounty.entryType === 'APPLICATION_FULL'; + const isCompetition = bounty.claimType === 'COMPETITION'; + const isOpenSingle = + bounty.entryType === 'OPEN' && bounty.claimType === 'SINGLE_CLAIM'; + + const { + register, + control, + handleSubmit, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(generalSchema), + defaultValues: { + title: bounty.title, + description: bounty.description, + category: BOUNTY_CATEGORIES.includes(bounty.category as BountyCategory) + ? (bounty.category as BountyCategory) + : 'DEVELOPMENT', + submissionVisibility: bounty.submissionVisibility, + documentation: bounty.submissionRequirements.documentation, + tweet: bounty.submissionRequirements.tweet, + demoVideo: bounty.submissionRequirements.demoVideo, + media: bounty.submissionRequirements.media, + reputationMinimum: bounty.reputationMinimum ?? undefined, + applicationWindowCloseAt: toLocalInput(bounty.applicationWindowCloseAt), + maxApplicants: bounty.maxApplicants ?? undefined, + shortlistSize: bounty.shortlistSize ?? undefined, + }, + }); + + const onSubmit = async (values: GeneralFormValues) => { + setSaving(true); + try { + await api.patch(`/organizations/${organizationId}/bounties/${bountyId}`, { + title: values.title, + description: values.description, + category: values.category, + submissionVisibility: values.submissionVisibility, + submissionRequirements: { + documentation: values.documentation, + tweet: values.tweet, + demoVideo: values.demoVideo, + media: values.media, + }, + reputationMinimum: isOpenSingle + ? numOrNull(values.reputationMinimum) + : undefined, + applicationWindowCloseAt: + isApplication && values.applicationWindowCloseAt + ? new Date(values.applicationWindowCloseAt).toISOString() + : undefined, + maxApplicants: + isApplication || (bounty.entryType === 'OPEN' && isCompetition) + ? numOrNull(values.maxApplicants) + : undefined, + shortlistSize: isCompetition + ? numOrNull(values.shortlistSize) + : undefined, + }); + toast.success('Bounty settings saved.'); + void queryClient.invalidateQueries({ + queryKey: bountyKeys.detail(bountyId), + }); + void queryClient.invalidateQueries({ + queryKey: bountyKeys.overview(organizationId, bountyId), + }); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to save settings'; + toast.error(message); + } finally { + setSaving(false); + } + }; + + const showMaxApplicants = + isApplication || (bounty.entryType === 'OPEN' && isCompetition); + + return ( +
+ {/* ── Scope ── */} +
+ + + + + +