diff --git a/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx b/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx index 13688d73..74b259b0 100644 --- a/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx +++ b/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx @@ -2,9 +2,7 @@ import { Suspense } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; import { - ArrowLeft, CalendarClock, Info, Loader2, @@ -18,8 +16,8 @@ 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 BountyTimelineSettingsTab from '@/components/organization/bounties/settings/BountyTimelineSettingsTab'; import BountySettingsPanel from '@/components/organization/bounties/manage/BountySettingsPanel'; import { useBountyOverview, @@ -64,15 +62,7 @@ function BountySettingsView() { return ( }>
-
- - - Back to dashboard - - +
@@ -153,7 +143,7 @@ function BountySettingsContent({ {/* Rewards — on-chain, immutable once funded. */} -
+

@@ -187,52 +177,13 @@ function BountySettingsContent({

- {/* Timeline — on-chain deadline, immutable once funded. */} + {/* Timeline — on-chain submission deadline (read-only) + editable + off-chain application window. */} -
- -
-

Timeline

-
- {overview.submissionDeadline ? ( -
-
Submission deadline
-
- -
-
- ) : ( - - )} - {overview.applicationWindowCloseAt && ( -
-
Applications close
-
- -
-
- )} - {overview.maxApplicants != null && ( - - )} - {overview.shortlistSize != null && ( - - )} -
-
-
+
{/* Close-out — cancel / refund + archive (moved from the dashboard). */} @@ -258,31 +209,3 @@ function OnChainNotice() {
); } - -function Row({ - label, - value, - hint, - capitalize, -}: { - label: string; - value: string; - hint?: string | null; - capitalize?: boolean; -}) { - return ( -
-
{label}
-
- - {value} - - {hint && ( - {hint} - )} -
-
- ); -} diff --git a/components/organization/bounties/manage/BountySettingsPanel.tsx b/components/organization/bounties/manage/BountySettingsPanel.tsx index 47d20666..7ddd5f20 100644 --- a/components/organization/bounties/manage/BountySettingsPanel.tsx +++ b/components/organization/bounties/manage/BountySettingsPanel.tsx @@ -93,7 +93,7 @@ export default function BountySettingsPanel({ const archiveBusy = archiveMutation.isPending || restoreMutation.isPending; return ( -
+
diff --git a/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx b/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx index 7f87f1c7..d9faef9c 100644 --- a/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx +++ b/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx @@ -45,7 +45,6 @@ const generalSchema = z.object({ 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(), }); @@ -74,15 +73,6 @@ const REQUIREMENTS: Array<{ { 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; @@ -161,7 +151,6 @@ function GeneralForm({ demoVideo: bounty.submissionRequirements.demoVideo, media: bounty.submissionRequirements.media, reputationMinimum: bounty.reputationMinimum ?? undefined, - applicationWindowCloseAt: toLocalInput(bounty.applicationWindowCloseAt), maxApplicants: bounty.maxApplicants ?? undefined, shortlistSize: bounty.shortlistSize ?? undefined, }, @@ -184,10 +173,6 @@ function GeneralForm({ 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) @@ -216,7 +201,7 @@ function GeneralForm({ isApplication || (bounty.entryType === 'OPEN' && isCompetition); return ( -
+ {/* ── Scope ── */}
)} - {isApplication && ( - - - - )} - {showMaxApplicants && ( 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())}`; +} + +function formatDate(iso?: string | null): string { + if (!iso) return 'Not set'; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? 'Not set' + : d.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); +} + +/** + * Timeline settings. The submission deadline is anchored on-chain at publish + * and is read-only. The application window close is off-chain (enforced by a + * backend cron), so it is editable here for application-mode bounties. + */ +export default function BountyTimelineSettingsTab({ + organizationId, + bountyId, +}: { + organizationId: string; + bountyId: string; +}) { + const { data: bounty, isLoading } = useBounty(bountyId); + + if (isLoading || !bounty) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + +function TimelineForm({ + 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 { + register, + handleSubmit, + formState: { isDirty }, + } = useForm<{ applicationWindowCloseAt: string }>({ + defaultValues: { + applicationWindowCloseAt: toLocalInput(bounty.applicationWindowCloseAt), + }, + }); + + const onSubmit = async (values: { applicationWindowCloseAt: string }) => { + setSaving(true); + try { + await api.patch(`/organizations/${organizationId}/bounties/${bountyId}`, { + applicationWindowCloseAt: values.applicationWindowCloseAt + ? new Date(values.applicationWindowCloseAt).toISOString() + : null, + }); + toast.success('Timeline 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 timeline'; + toast.error(message); + } finally { + setSaving(false); + } + }; + + return ( +
+ {/* Submission deadline — on-chain, read-only. */} +
+
+
+

+ Submission deadline +

+

+ {formatDate(bounty.submissionDeadline)} +

+
+ + + On-chain + +
+

+ Anchored in the escrow at publish; it can't be changed after + funding. +

+
+ + {/* Application window close — off-chain, editable (application modes). */} + {isApplication ? ( + +
+

+ Applications close +

+

+ When the application window closes. Off-chain, so you can adjust + it any time before it passes. +

+
+ +
+ + {saving ? ( + <> + + Saving… + + ) : ( + 'Save changes' + )} + +
+ + ) : ( +
+ This bounty has no application window — builders submit directly. +
+ )} +
+ ); +}