+ {/* Winners are picked, confirmed and paid in the Winners
+ section. This tab is now read-only scoring standings. */}
+
+
+
+
+
-
- Results published
+
+ {resultsPublished
+ ? 'Winners are published'
+ : 'Ready to pick winners?'}
- Winner rankings are live. This hackathon's results
- have been finalized.
+ {resultsPublished
+ ? 'Review and pay winners in the Winners section.'
+ : 'When judging is done, pick a winner for each prize in the Winners section.'}
- )}
-
- {!resultsPublished &&
- canPublishResults &&
- judgingResults.length > 0 && (
-
-
-
-
-
-
-
- Finalize Competition
-
-
- Publish the current rankings to name the winners.
-
-
-
-
setIsPublishDialogOpen(true)}
- disabled={isPublishing}
- className='bg-primary text-primary-foreground hover:bg-primary/90 px-8 font-bold shadow-lg'
- >
- {isPublishing ? 'Publishing...' : 'Publish Results'}
-
-
- )}
-
- {!resultsPublished && (
-
- )}
-
- {winners.length > 0 && (
-
-
-
- Final Winners
-
-
-
- )}
-
- {tracks.length > 0 && judgingResults.length > 0 && (
-
- )}
+
+
+ Go to Winners
+
+
+
+
+ {/* Read-only standings: the scores judges have submitted. */}
- Current Standings
+ Current standings
{isFetchingResults && judgingResults.length === 0 ? (
@@ -1122,8 +1105,7 @@ export default function JudgingPage() {
hackathonId={hackathonId}
totalJudges={currentJudges.length}
criteria={criteria}
- canManage={canManageJudges}
- winnerOverrides={judgingSummary?.winnerOverrides}
+ canManage={false}
/>
{/* Pagination Controls for Results */}
@@ -1160,8 +1142,8 @@ export default function JudgingPage() {
>
) : (
)}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx
index da4ea8e5d..2bacb2194 100644
--- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx
@@ -9,7 +9,7 @@ import {
Check,
} from 'lucide-react';
import { useHackathons } from '@/hooks/use-hackathons';
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
import { useHackathonAnalytics } from '@/hooks/use-hackathon-analytics';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { HackathonStatistics } from '@/components/organization/hackathons/details/HackathonStatistics';
@@ -17,22 +17,14 @@ import { HackathonCharts } from '@/components/organization/hackathons/details/Ha
import { HackathonTimeline } from '@/components/organization/hackathons/details/HackathonTimeline';
import { AuthGuard } from '@/components/auth';
import Loading from '@/components/Loading';
-import HackathonPublishedModal from '@/components/organization/hackathons/new/tabs/components/review/HackathonPublishedModal';
import { ExportButton } from '@/components/organization/hackathons/details/ExportButton';
-import type { PublishResponseData } from '@/hooks/use-hackathon-publish';
-
-const STORAGE_KEY = 'boundless_hackathon_published';
+import HackathonPublishStatusBanner from '@/components/organization/hackathons/HackathonPublishStatusBanner';
export default function HackathonPage() {
const params = useParams();
const organizationId = params.id as string;
const hackathonId = params.hackathonId as string;
- const [publishedModalData, setPublishedModalData] = useState<{
- publishResponse: PublishResponseData;
- organizationId: string;
- } | null>(null);
-
const { currentHackathon, currentLoading, currentError, fetchHackathon } =
useHackathons({
organizationId,
@@ -51,37 +43,6 @@ export default function HackathonPage() {
}
}, [organizationId, hackathonId, fetchHackathon]);
- useEffect(() => {
- try {
- const raw = sessionStorage.getItem(STORAGE_KEY);
- if (!raw) return;
- const payload = JSON.parse(raw) as {
- organizationId: string;
- id: string;
- slug: string;
- publishedAt: string;
- message: string;
- escrowAddress: string;
- transactionHash: string | null;
- };
- if (payload.id !== hackathonId) return;
- sessionStorage.removeItem(STORAGE_KEY);
- setPublishedModalData({
- publishResponse: {
- id: payload.id,
- slug: payload.slug,
- publishedAt: payload.publishedAt,
- message: payload.message,
- escrowAddress: payload.escrowAddress,
- transactionHash: payload.transactionHash,
- },
- organizationId: payload.organizationId,
- });
- } catch {
- // ignore
- }
- }, [hackathonId]);
-
if (currentLoading) {
return (
@@ -136,15 +97,6 @@ export default function HackathonPage() {
return (
}>
-
{
- if (!open) setPublishedModalData(null);
- }}
- publishResponse={publishedModalData?.publishResponse ?? null}
- organizationId={publishedModalData?.organizationId}
- />
-
{/* Hero Section with Hackathon Name */}
@@ -161,6 +113,16 @@ export default function HackathonPage() {
{/* Main Content */}
+ {/* Resumes an in-flight publish (DRAFT_AWAITING_FUNDING) on load. */}
+
{
+ void fetchHackathon(hackathonId);
+ }}
+ />
+
{/* Statistics Section */}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
index 3d6c79873..55d8c637a 100644
--- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import MetricsCard from '@/components/organization/cards/MetricsCard';
import { useParams } from 'next/navigation';
+import { Info } from 'lucide-react';
import { useHackathons } from '@/hooks/use-hackathons';
import { getHackathonStatistics, getHackathon } from '@/lib/api/hackathons';
import { AuthGuard } from '@/components/auth';
@@ -334,6 +335,17 @@ const ParticipantsPage: React.FC = () => {
/>
+
+
+
+ Shortlist the
+ entries you want judged. Only shortlisted submissions move on to
+ the Judging page, where your judges score them. Use the status
+ filter to find submitted entries, then shortlist or disqualify
+ each one.
+
+
+
prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length,
- [prizeTiers]
- );
- const winners = useMemo(
- () =>
- submissions.filter(s => (s.rank && s.rank <= maxRank) || s.isTrackWinner),
- [submissions, maxRank]
- );
- const hasWinners = winners.length > 0;
-
- const handleRankChangeWrapper = async (
- submissionId: string,
- newRank: number | null
- ) => {
- await handleRankChange(
- submissions,
- setSubmissions,
- submissionId,
- newRank,
- maxRank,
- organizationId,
- hackathonId
- );
- };
-
- const handlePublishSuccess = () => {
- refreshEscrow();
- refetchDistributionStatus();
- refetchHackathon();
- };
-
- return (
- }>
-
-
-
- {isLoading && (
-
-
-
-
- Loading rewards data...
-
-
- Please wait while we fetch the information
-
-
-
- )}
-
- {!isLoading && error && (
-
-
- Error Loading Data
- {error}
-
- )}
-
- {!isLoading && distributionError && (
-
-
- Distribution Status Error
- {distributionError}
-
- )}
-
- {!isLoading && !error && (
-
setIsPublishWizardOpen(true)}
- onRankChange={handleRankChangeWrapper}
- distributionStatus={distributionStatus}
- isLoadingDistributionStatus={isLoadingDistributionStatus}
- onRefreshDistributionStatus={refetchDistributionStatus}
- resultsPublished={resultsPublished}
- escrowAddress={hackathon?.escrowAddress || hackathon?.contractId}
- trackWinners={trackWinners}
- />
- )}
-
-
-
-
- );
+import { redirect } from 'next/navigation';
+
+/**
+ * The operational "Rewards" page was renamed to "Winners" (pick winners ->
+ * announce -> pay). Keep this path as a permanent redirect so existing
+ * bookmarks and links still resolve.
+ */
+export default async function RewardsRedirect({
+ params,
+}: {
+ params: Promise<{ id: string; hackathonId: string }>;
+}) {
+ const { id, hackathonId } = await params;
+ redirect(`/organizations/${id}/hackathons/${hackathonId}/winners`);
}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx
index 42a10a070..a20de2326 100644
--- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx
@@ -71,7 +71,7 @@ export default function SettingsPage() {
banner: h.banner,
description: h.description,
categories: h.categories,
- venueType: h.venueType.toLowerCase() as any,
+ venueType: h.venueType?.toLowerCase() as any,
country: h.country,
state: h.state,
city: h.city,
@@ -104,23 +104,24 @@ export default function SettingsPage() {
const getParticipantData = (h: Hackathon | null) => {
if (!h) return undefined;
+ const enabledTabs = h.enabledTabs ?? [];
return {
- participantType: h.participantType.toLowerCase() as any,
+ participantType: h.participantType?.toLowerCase() as any,
teamMin: h.teamMin,
teamMax: h.teamMax,
require_github: h.requireGithub,
require_demo_video: h.requireDemoVideo,
require_other_links: h.requireOtherLinks,
- detailsTab: h.enabledTabs.includes('detailsTab'),
- participantsTab: h.enabledTabs.includes('participantsTab'),
- resourcesTab: h.enabledTabs.includes('resourcesTab'),
- submissionTab: h.enabledTabs.includes('submissionTab'),
- announcementsTab: h.enabledTabs.includes('announcementsTab'),
- discussionTab: h.enabledTabs.includes('discussionTab'),
- winnersTab: h.enabledTabs.includes('winnersTab'),
- sponsorsTab: h.enabledTabs.includes('sponsorsTab'),
- joinATeamTab: h.enabledTabs.includes('joinATeamTab'),
- rulesTab: h.enabledTabs.includes('rulesTab'),
+ detailsTab: enabledTabs.includes('detailsTab'),
+ participantsTab: enabledTabs.includes('participantsTab'),
+ resourcesTab: enabledTabs.includes('resourcesTab'),
+ submissionTab: enabledTabs.includes('submissionTab'),
+ announcementsTab: enabledTabs.includes('announcementsTab'),
+ discussionTab: enabledTabs.includes('discussionTab'),
+ winnersTab: enabledTabs.includes('winnersTab'),
+ sponsorsTab: enabledTabs.includes('sponsorsTab'),
+ joinATeamTab: enabledTabs.includes('joinATeamTab'),
+ rulesTab: enabledTabs.includes('rulesTab'),
};
};
@@ -128,15 +129,7 @@ export default function SettingsPage() {
if (!h) return undefined;
const adv = h.metadata?.advancedSettings;
return {
- isPublic: adv?.isPublic ?? true,
- allowLateRegistration: adv?.allowLateRegistration ?? false,
- requireApproval: adv?.requireApproval ?? false,
maxParticipants: adv?.maxParticipants,
- customDomain: adv?.customDomain || '',
- enableDiscord: adv?.enableDiscord ?? !!h.discord,
- discordInviteLink: adv?.discordInviteLink || h.discord || '',
- enableTelegram: adv?.enableTelegram ?? !!h.telegram,
- telegramInviteLink: adv?.telegramInviteLink || h.telegram || '',
};
};
@@ -365,6 +358,7 @@ export default function SettingsPage() {
@@ -373,6 +367,9 @@ export default function SettingsPage() {
organizationId={organizationId}
hackathonId={hackathonId}
initialData={getAdvancedData(hackathon)}
+ initialVisibility={
+ hackathon?.visibility === 'PRIVATE' ? 'PRIVATE' : 'PUBLIC'
+ }
onSaveSuccess={fetchHackathon}
/>
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/winners/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/winners/page.tsx
new file mode 100644
index 000000000..eb0f19876
--- /dev/null
+++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/winners/page.tsx
@@ -0,0 +1,338 @@
+'use client';
+
+import React, { useState, useMemo, useEffect } from 'react';
+import { useParams } from 'next/navigation';
+import { Loader2, AlertCircle } from 'lucide-react';
+import { toast } from 'sonner';
+import PublishWinnersWizard from '@/components/organization/hackathons/rewards/PublishWinnersWizard';
+import { RewardsPageHeader } from '@/components/organization/hackathons/rewards/RewardsPageHeader';
+import { RewardsPageContent } from '@/components/organization/hackathons/rewards/RewardsPageContent';
+import { useHackathonRewards } from '@/hooks/use-hackathon-rewards';
+import {
+ publishJudgingResults,
+ getJudgingCompleteness,
+ type JudgingCompletenessPreview,
+} from '@/lib/api/hackathons/judging';
+import type { WinnersBoard as WinnersBoardData } from '@/lib/api/hackathons/winners';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { AuthGuard } from '@/components/auth';
+import Loading from '@/components/Loading';
+
+export default function WinnersPage() {
+ const params = useParams();
+ const organizationId = params.id as string;
+ const hackathonId = params.hackathonId as string;
+
+ const {
+ submissions,
+ prizeTiers,
+ isLoading,
+ isLoadingSubmissions,
+ error,
+ refetchHackathon,
+ resultsPublished,
+ hackathon,
+ trackWinners,
+ } = useHackathonRewards(organizationId, hackathonId);
+
+ const [isPublishWizardOpen, setIsPublishWizardOpen] = useState(false);
+
+ // Winners board snapshot (reported up from WinnersBoard). Drives the
+ // "X of Y prizes have a winner" summary and gates Confirm so results can
+ // never be published with zero winners.
+ const [board, setBoard] = useState(null);
+ const placements = useMemo(
+ () => board?.prizes.flatMap(p => p.placements) ?? [],
+ [board]
+ );
+ const totalPlacements = placements.length;
+ const winnersChosen = placements.filter(pl => pl.selected).length;
+ const eligiblePlacements = placements.filter(
+ pl => pl.candidates.length > 0
+ ).length;
+ // Prizes with no winner (withheld or simply unfilled). Their escrowed money
+ // is not paid out โ disclosed at confirm so the organizer acknowledges it.
+ const unawardedPlacements = placements.filter(pl => !pl.selected);
+ const unawardedCount = unawardedPlacements.length;
+ const unawardedAmount = unawardedPlacements.reduce(
+ (sum, pl) => sum + (Number.parseFloat(pl.amount) || 0),
+ 0
+ );
+ const unawardedCurrency = placements[0]?.currency ?? 'USDC';
+
+ // โโ Confirm winners (publish results) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // Reuses the judging completeness + acceptPartial UX so publishing here
+ // handles incomplete judging the same way.
+ const [isPublishResultsDialogOpen, setIsPublishResultsDialogOpen] =
+ useState(false);
+ const [isPublishingResults, setIsPublishingResults] = useState(false);
+ const [completeness, setCompleteness] =
+ useState(null);
+ const [completenessLoading, setCompletenessLoading] = useState(false);
+ const [acceptPartial, setAcceptPartial] = useState(false);
+ // Separate acknowledgement that unawarded prizes won't be paid out.
+ const [acceptUnawarded, setAcceptUnawarded] = useState(false);
+
+ // Pull the completeness snapshot every time the dialog opens so the
+ // organizer sees fresh numbers before publishing.
+ useEffect(() => {
+ if (!isPublishResultsDialogOpen) {
+ setAcceptPartial(false);
+ setAcceptUnawarded(false);
+ return;
+ }
+ let cancelled = false;
+ setCompletenessLoading(true);
+ getJudgingCompleteness(organizationId, hackathonId)
+ .then(res => {
+ if (cancelled) return;
+ if (res.success && res.data) setCompleteness(res.data);
+ })
+ .catch(() => {
+ // Non-fatal: the dialog still works, organizer just won't see
+ // the incompleteness summary.
+ })
+ .finally(() => {
+ if (!cancelled) setCompletenessLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [isPublishResultsDialogOpen, organizationId, hackathonId]);
+
+ const handlePublishResults = async () => {
+ setIsPublishingResults(true);
+ try {
+ const res = await publishJudgingResults(organizationId, hackathonId, {
+ acceptPartial,
+ });
+ if (res.success) {
+ toast.success('Winners confirmed and announced!');
+ setIsPublishResultsDialogOpen(false);
+ setAcceptPartial(false);
+ await refetchHackathon();
+ } else {
+ toast.error(
+ (res as { message?: string }).message || 'Failed to confirm winners'
+ );
+ }
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : 'Failed to confirm winners'
+ );
+ } finally {
+ setIsPublishingResults(false);
+ }
+ };
+
+ // `maxRank` is the number of OVERALL prize tier slots; track tiers
+ // are not rank-numbered so they don't contribute to this cap. The
+ // rank-based rendering (podium, rank-keyed lookups) still uses
+ // `maxRank`. Track winners flow through `isTrackWinner` instead.
+ const maxRank = useMemo(
+ () => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length,
+ [prizeTiers]
+ );
+ const winners = useMemo(
+ () =>
+ submissions.filter(s => (s.rank && s.rank <= maxRank) || s.isTrackWinner),
+ [submissions, maxRank]
+ );
+ const hasWinners = winners.length > 0;
+
+ // Pay step gate: the hackathon must be funded on-chain (events contract
+ // published) before winners can be paid. The Hackathon type has no
+ // dedicated escrow-event flag, so gate on the lifecycle status โ a
+ // hackathon that left DRAFT/DRAFT_AWAITING_FUNDING is funded on-chain.
+ const canReward = useMemo(() => {
+ const status = hackathon?.status;
+ if (!status) return false;
+ return status !== 'DRAFT' && status !== 'DRAFT_AWAITING_FUNDING';
+ }, [hackathon?.status]);
+
+ return (
+ }>
+
+
+
+ {isLoading && (
+
+
+
+
+ Loading winners...
+
+
+ Please wait while we fetch the information
+
+
+
+ )}
+
+ {!isLoading && error && (
+
+
+ Error Loading Data
+ {error}
+
+ )}
+
+ {!isLoading && !error && (
+
setIsPublishWizardOpen(true)}
+ onPublishResults={() => setIsPublishResultsDialogOpen(true)}
+ isPublishingResults={isPublishingResults}
+ resultsPublished={resultsPublished}
+ canReward={canReward}
+ trackWinners={trackWinners}
+ onBoardLoaded={setBoard}
+ winnersChosen={winnersChosen}
+ totalPlacements={totalPlacements}
+ eligiblePlacements={eligiblePlacements}
+ />
+ )}
+
+ refetchHackathon()}
+ />
+
+ {/* Confirm winners: locks the rankings + announces, then unlocks pay. */}
+
+
+
+ Confirm the winners?
+
+ This locks in the {winnersChosen} winner
+ {winnersChosen === 1 ? '' : 's'} you picked and announces them
+ to participants. You can pay out prizes next. The winner list
+ cannot be changed afterwards.
+
+
+
+ {completenessLoading && (
+
+ Checking judging progressโฆ
+
+ )}
+
+ {completeness && completeness.complete && (
+
+ All {completeness.expectedJudgeCount} active judges have scored
+ every shortlisted submission.
+
+ )}
+
+ {completeness && !completeness.complete && (
+
+
+
Judging is incomplete.
+
+ {completeness.incompleteSubmissionCount} of{' '}
+ {completeness.totalShortlisted} shortlisted submissions are
+ missing scores from one or more active judges.
+
+
+ {completeness.incompleteJudges.length > 0 && (
+
+
+ Judges still scoring
+
+
+ {completeness.incompleteJudges.map(j => (
+
+ {j.name}
+
+ {j.missingCount} left
+
+
+ ))}
+
+
+ )}
+
+ setAcceptPartial(e.target.checked)}
+ className='accent-primary mt-0.5 h-3.5 w-3.5'
+ />
+
+ I understand some judges have not finished, and I want to
+ confirm the winners anyway.
+
+
+
+ )}
+
+ {unawardedCount > 0 && (
+
+ setAcceptUnawarded(e.target.checked)}
+ className='accent-primary mt-0.5 h-3.5 w-3.5'
+ />
+
+ {unawardedCount} prize{unawardedCount === 1 ? '' : 's'} (
+ {unawardedAmount.toLocaleString('en-US')} {unawardedCurrency})
+ won't be awarded. That money stays in the prize pool and
+ is not paid out. I understand.
+
+
+ )}
+
+
+
+ Cancel
+
+ 0 && !acceptUnawarded) ||
+ Boolean(
+ completeness && !completeness.complete && !acceptPartial
+ )
+ }
+ className='bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50'
+ >
+ {isPublishingResults
+ ? 'Confirmingโฆ'
+ : completeness && !completeness.complete && acceptPartial
+ ? 'Confirm anyway'
+ : 'Confirm winners'}
+
+
+
+
+
+
+ );
+}
diff --git a/app/(landing)/organizations/[id]/hackathons/drafts/[draftId]/page.tsx b/app/(landing)/organizations/[id]/hackathons/drafts/[draftId]/page.tsx
index e12dbe4a2..cdfc67f2b 100644
--- a/app/(landing)/organizations/[id]/hackathons/drafts/[draftId]/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/drafts/[draftId]/page.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Suspense } from 'react';
import NewHackathonTab from '@/components/organization/hackathons/new/NewHackathonTab';
import { AuthGuard } from '@/components/auth';
import Loading from '@/components/Loading';
@@ -16,7 +16,9 @@ const DraftPage = async ({ params }: DraftPageProps) => {
return (
}>
-
+ }>
+
+
);
diff --git a/app/(landing)/organizations/[id]/hackathons/new/page.tsx b/app/(landing)/organizations/[id]/hackathons/new/page.tsx
index fc2883f31..06cfb02d7 100644
--- a/app/(landing)/organizations/[id]/hackathons/new/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/new/page.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Suspense } from 'react';
import NewHackathonTab from '@/components/organization/hackathons/new/NewHackathonTab';
import Loading from '@/components/Loading';
import { AuthGuard } from '@/components/auth';
@@ -6,7 +6,9 @@ const page = () => {
return (
}>
-
+ }>
+
+
);
diff --git a/app/(landing)/organizations/[id]/hackathons/page.tsx b/app/(landing)/organizations/[id]/hackathons/page.tsx
index 10529736e..35bc1e99d 100644
--- a/app/(landing)/organizations/[id]/hackathons/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/page.tsx
@@ -12,6 +12,7 @@ import {
Settings,
Eye,
Trash2,
+ Sparkles,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
@@ -24,6 +25,7 @@ import {
import Link from 'next/link';
import Image from 'next/image';
import { BoundlessButton } from '@/components/buttons';
+import GenerateWithAiDialog from '@/components/organization/hackathons/new/GenerateWithAiDialog';
import { useHackathons } from '@/hooks/use-hackathons';
import { useDeleteHackathon } from '@/hooks/hackathon/use-delete-hackathon';
import type { Hackathon, HackathonDraft } from '@/lib/api/hackathons';
@@ -134,6 +136,7 @@ export default function HackathonsPage() {
const [tab, setTab] = useState<'published' | 'drafts'>('published');
const [categoryFilter, setCategoryFilter] = useState('all');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [hackathonToDelete, setHackathonToDelete] = useState<{
id: string;
title: string;
@@ -282,12 +285,22 @@ export default function HackathonsPage() {
{stats.drafts} drafts
-
-
-
- Host Hackathon
+
+
setAiDialogOpen(true)}
+ >
+
+ Generate with AI
-
+
+
+
+ Host Hackathon
+
+
+
{/* Tabs */}
@@ -576,29 +589,43 @@ export default function HackathonsPage() {
(sum: number, tier: any) => sum + (tier.amount || 0),
0
) || 0;
+ // A hackathon mid-publish stays in the drafts list but is
+ // no longer editable โ route it to the management page where
+ // the publish-status banner resumes/finishes it.
+ const isPublishing =
+ draft.status === 'DRAFT_AWAITING_FUNDING';
+ const targetHref = isPublishing
+ ? `/organizations/${organizationId}/hackathons/${draft.id}`
+ : `/organizations/${organizationId}/hackathons/drafts/${draft.id}`;
return (
- router.push(
- `/organizations/${organizationId}/hackathons/drafts/${draft.id}`
- )
- }
+ onClick={() => router.push(targetHref)}
tabIndex={0}
role='button'
- aria-label={`Edit draft ${title}`}
+ aria-label={
+ isPublishing
+ ? `View publishing hackathon ${title}`
+ : `Edit draft ${title}`
+ }
>
- Draft
+ {isPublishing ? 'Publishing' : 'Draft'}
- {completion}% complete
+ {isPublishing
+ ? 'Finalizingโฆ'
+ : `${completion}% complete`}
{endDate && (
@@ -643,29 +670,29 @@ export default function HackathonsPage() {
>
- {
- e.stopPropagation();
- handleDeleteClick(draft.id, 'draft');
- }}
- className='flex h-9 w-9 items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900/70 text-zinc-400 hover:border-red-600 hover:text-red-500'
- title='Delete Draft'
- disabled={isDeleting}
- >
-
-
+ {!isPublishing && (
+ {
+ e.stopPropagation();
+ handleDeleteClick(draft.id, 'draft');
+ }}
+ className='flex h-9 w-9 items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900/70 text-zinc-400 hover:border-red-600 hover:text-red-500'
+ title='Delete Draft'
+ disabled={isDeleting}
+ >
+
+
+ )}
{
e.stopPropagation();
- router.push(
- `/organizations/${organizationId}/hackathons/drafts/${draft.id}`
- );
+ router.push(targetHref);
}}
>
- Continue
+ {isPublishing ? 'View' : 'Continue'}
@@ -687,6 +714,11 @@ export default function HackathonsPage() {
isDeleting={isDeleting}
/>
)}
+
);
diff --git a/app/(landing)/organizations/[id]/treasury/page.tsx b/app/(landing)/organizations/[id]/treasury/page.tsx
new file mode 100644
index 000000000..20779541a
--- /dev/null
+++ b/app/(landing)/organizations/[id]/treasury/page.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { useState } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { Landmark, Users } from 'lucide-react';
+import { AuthGuard } from '@/components/auth';
+import Loading from '@/components/Loading';
+import WalletsSection from '@/components/organization/treasury/WalletsSection';
+import SendFunds from '@/components/organization/treasury/SendFunds';
+import Receipts from '@/components/organization/treasury/Receipts';
+import AuditLog from '@/components/organization/treasury/AuditLog';
+
+type TabKey = 'wallets' | 'spend' | 'receipts' | 'activity';
+
+const TABS: { key: TabKey; label: string }[] = [
+ { key: 'wallets', label: 'Wallets' },
+ { key: 'spend', label: 'Send funds' },
+ { key: 'receipts', label: 'Receipts' },
+ { key: 'activity', label: 'Activity' },
+];
+
+export default function TreasuryPage() {
+ const params = useParams();
+ const organizationId = params.id as string;
+ const [tab, setTab] = useState('wallets');
+
+ return (
+ }>
+
+
+
+
+
+
+ Treasury
+
+
+
+
+
Members & roles
+
+
+
+
+
+
+ {TABS.map(t => (
+ setTab(t.key)}
+ className={`-mb-px border-b-2 px-4 py-2.5 text-sm transition-colors ${
+ tab === t.key
+ ? 'border-primary text-white'
+ : 'border-transparent text-gray-500 hover:text-gray-300'
+ }`}
+ >
+ {t.label}
+
+ ))}
+
+
+ {tab === 'wallets' && (
+
+ )}
+ {tab === 'spend' &&
}
+ {tab === 'receipts' &&
}
+ {tab === 'activity' &&
}
+
+
+
+ );
+}
diff --git a/app/(landing)/organizations/[id]/treasury/receipts/[receiptId]/page.tsx b/app/(landing)/organizations/[id]/treasury/receipts/[receiptId]/page.tsx
new file mode 100644
index 000000000..1bb08e566
--- /dev/null
+++ b/app/(landing)/organizations/[id]/treasury/receipts/[receiptId]/page.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import { useState } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import { ArrowLeft, ExternalLink, Loader2, Mail, Printer } from 'lucide-react';
+import { toast } from 'sonner';
+import { AuthGuard } from '@/components/auth';
+import Loading from '@/components/Loading';
+import { BoundlessButton } from '@/components/buttons';
+import { useReceipt, useSendReceipt } from '@/features/treasury';
+
+function formatUsdc(value: string): string {
+ const n = Number(value);
+ return Number.isFinite(n)
+ ? n.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+ : value;
+}
+
+export default function ReceiptPage() {
+ const params = useParams();
+ const router = useRouter();
+ const organizationId = params.id as string;
+ const receiptId = params.receiptId as string;
+
+ const {
+ data: receipt,
+ isLoading,
+ error,
+ } = useReceipt(organizationId, receiptId);
+ const sendReceipt = useSendReceipt(organizationId);
+
+ const emailReceipt = async () => {
+ try {
+ await sendReceipt.mutateAsync({ receiptId });
+ toast.success('Receipt emailed to you');
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : 'Could not email the receipt'
+ );
+ }
+ };
+
+ return (
+ }>
+
+ {/* Toolbar (hidden when printing) */}
+
+
router.back()}
+ className='inline-flex items-center gap-1 text-sm text-gray-400 hover:text-white'
+ >
+
+ Back
+
+
+
+
+ {sendReceipt.isPending ? (
+
+ ) : (
+
+ )}
+ Email me a copy
+
+
+
window.print()} disabled={!receipt}>
+
+
+ Print / Save as PDF
+
+
+
+
+
+ {isLoading ? (
+
+
+ Loading receiptโฆ
+
+ ) : error || !receipt ? (
+
+ Receipt not found.
+
+ ) : (
+
+ {/* Header */}
+
+
+
Boundless
+
+ Receipt
+
+
+
+
+ {receipt.receiptNumber}
+
+ {receipt.status === 'VOID' && (
+
+ VOID
+
+ )}
+
+
+
+ {/* Amount */}
+
+
{receipt.typeLabel}
+
+ {formatUsdc(receipt.amount)}
+
+ {receipt.currency}
+
+
+
+
+ {/* Details */}
+
+ {receipt.fromLabel && {receipt.fromLabel}
}
+ {receipt.toAddress && (
+
+
+ {receipt.toAddress}
+
+
+ )}
+ {receipt.description && (
+ {receipt.description}
+ )}
+
+ {new Date(receipt.issuedAt).toLocaleString()}
+
+
+ {receipt.status === 'VOID' ? 'Void' : 'Issued'}
+
+ {receipt.onChainTxHash && (
+
+ {receipt.explorerUrl ? (
+
+
+ {receipt.onChainTxHash}
+
+
+
+ ) : (
+
+ {receipt.onChainTxHash}
+
+ )}
+
+ )}
+
+
+ {/* Footer */}
+
+
+ This is an official receipt from Boundless. Keep it for your
+ records. Questions? support@boundlessfi.xyz
+
+
+
+ )}
+
+
+ );
+}
+
+function Row({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{label}
+
+ {children}
+
+
+ );
+}
diff --git a/app/(landing)/organizations/layout.tsx b/app/(landing)/organizations/layout.tsx
index 4cf71c888..6716491fa 100644
--- a/app/(landing)/organizations/layout.tsx
+++ b/app/(landing)/organizations/layout.tsx
@@ -11,7 +11,6 @@ import {
useNavigationLoading,
} from '@/lib/providers';
import { cn } from '@/lib/utils';
-import NewHackathonSidebar from '@/components/organization/hackathons/new/NewHackathonSidebar';
import HackathonSidebar from '@/components/organization/hackathons/details/HackathonSidebar';
import HackathonNavigationLoader from '@/components/organization/hackathons/details/HackathonNavigationLoader';
@@ -24,13 +23,12 @@ export default function OrganizationsLayout({
const showOrganizationSidebar =
pathname !== '/organizations' && pathname.startsWith('/organizations');
- const showNewHackathonSidebar = pathname.includes('/hackathons/new');
const showNewGrantSidebar = pathname.includes('/grants/new');
- // Show hackathon sidebar only on hackathon detail pages (not on list or new pages)
+ // One shell for the whole hackathon lifecycle: create (/new), draft editing
+ // (/drafts/[id]), and published management (/[hackathonId]/...). Everything
+ // under /hackathons/ except the list page itself.
const showHackathonSidebar =
- pathname.includes('/hackathons/') &&
- !pathname.endsWith('/hackathons') &&
- !pathname.includes('/hackathons/new');
+ pathname.includes('/hackathons/') && !pathname.endsWith('/hackathons');
const getOrgIdFromPath = () => {
if (pathname.startsWith('/organizations/')) {
const pathParts = pathname.split('/');
@@ -49,7 +47,6 @@ export default function OrganizationsLayout({
{showOrganizationSidebar &&
- !showNewHackathonSidebar &&
!showNewGrantSidebar &&
!showHackathonSidebar && }
- {showNewHackathonSidebar && }
{showHackathonSidebar && (
)}
diff --git a/app/(landing)/projects/[slug]/page.tsx b/app/(landing)/projects/[slug]/page.tsx
index 5f3af021e..393a8e556 100644
--- a/app/(landing)/projects/[slug]/page.tsx
+++ b/app/(landing)/projects/[slug]/page.tsx
@@ -5,7 +5,7 @@ import { ProjectLoading } from '@/components/project-details/project-loading';
import { getCrowdfundingProject } from '@/features/projects/api';
import type { Crowdfunding } from '@/features/projects/types';
import { use, useEffect, useState } from 'react';
-import { useSearchParams, notFound } from 'next/navigation';
+import { useSearchParams, notFound, useRouter } from 'next/navigation';
import {
getSubmissionDetails,
getHackathon,
@@ -34,6 +34,15 @@ function ProjectContent({
const [project, setProject] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
+ const router = useRouter();
+
+ // Crowdfunding campaigns have their own dedicated, jargon-free page. Never
+ // show them inside the generic project layout โ send them there instead.
+ const crowdfundingSlug =
+ !isSubmission && project?.v2Status ? project.slug : null;
+ useEffect(() => {
+ if (crowdfundingSlug) router.replace(`/crowdfunding/${crowdfundingSlug}`);
+ }, [crowdfundingSlug, router]);
useEffect(() => {
const fetchSubmission = async (submissionId: string) => {
@@ -108,7 +117,7 @@ function ProjectContent({
fetchProjectData();
}, [id, isSubmission]);
- if (loading) {
+ if (loading || crowdfundingSlug) {
return ;
}
@@ -116,10 +125,6 @@ function ProjectContent({
notFound();
}
- if (error || !project) {
- notFound();
- }
-
return (
diff --git a/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx b/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx
index c6e48bb9e..4d43eb781 100644
--- a/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx
+++ b/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx
@@ -15,6 +15,7 @@ import { AuthGuard } from '@/components/auth/AuthGuard';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { ScoreSlider } from '@/components/judge/ScoreSlider';
+import JudgeAiAssist from '@/components/judge/JudgeAiAssist';
import {
KeyboardShortcuts,
ShortcutDef,
@@ -164,6 +165,32 @@ function ScorePage() {
[scores, criteria]
);
+ // AI assist (advisory): id -> label for readable rows, and an apply helper
+ // that copies the AI's per-criterion scores into the judge's own sliders to
+ // review and adjust. Keys align because both use the criterion id.
+ const criterionLabels = useMemo(() => {
+ const out: Record = {};
+ for (const c of criteria) {
+ out[getCriterionKey(c)] = c.title || c.name || getCriterionKey(c);
+ }
+ return out;
+ }, [criteria]);
+
+ const applyAiScores = useCallback(
+ (aiScores: Array<{ criterionId: string; score: number }>) => {
+ setScores(prev => {
+ const next = { ...prev };
+ for (const cs of aiScores) {
+ if (criteria.some(c => getCriterionKey(c) === cs.criterionId)) {
+ next[cs.criterionId] = Math.min(10, Math.max(0, cs.score));
+ }
+ }
+ return next;
+ });
+ },
+ [criteria]
+ );
+
// ---- handlers ----
const handleScoreChange = useCallback((key: string, value: number | '') => {
if (value === '') {
@@ -596,6 +623,16 @@ function ScorePage() {
)}
+
+ {criteria.length > 0 && (
+
+ )}
diff --git a/app/layout.tsx b/app/layout.tsx
index dfc4d5366..126a4924e 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -11,7 +11,6 @@ import {
generateWebsiteStructuredData,
} from '@/lib/structured-data';
import NextTopLoader from 'nextjs-toploader';
-import DevelopmentStatusModal from '@/components/DevelopmentStatusModal';
const inter = Inter({
variable: '--font-inter',
@@ -95,9 +94,8 @@ export default function RootLayout({
{children}
-
+
-