-
-
- 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 (
+
+ );
+}
+
+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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+}
+
+function Section({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+ {children}
+
+ );
+}
+
+function Field({
+ label,
+ required,
+ error,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ error?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+ {error &&
{error}
}
+
+ );
+}