diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md index 636ca0ee..55575b3d 100644 --- a/docs/sites/mainweb.md +++ b/docs/sites/mainweb.md @@ -45,17 +45,17 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p | `/judge`, `/judge/register` | Judge home / apply | | `/scan` | QR scanning | | `/admin` | Staff home | -| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events) | +| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events). `[id]` is the name slug (`hacklytics-digital-bloom`), not a percent-encoded name. | | `/admin/members` | Membership admin | | `/admin/attendees` | Attendee tools | -| `/admin/judging` | Judging admin | +| `/admin/judging` | Judging admin (sync submissions + assign judges live here) | | `/admin/initiatives` | Initiative / proposal review | | `/admin/bootcamp` | Bootcamp attendance | | `/admin/staff` | Admin users | | `/admin/analytics` | Overview | | `/admin/audit` | Audit log | | `/admin/projects` | Project admin | -| `/admin/setup` | First-run wizard | +| `/admin/setup` | Redirects to `/admin/judging`. Do not create a second hackathon from judging — create it under Hackathons. | ## API routes diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 7c5b51d8..a20a5991 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -1061,6 +1061,22 @@ describe("Hackathon end-to-end flow", () => { ); }); + it("opens an edition from a percent-encoded name (old admin dashboard links)", async () => { + // `/admin/hackathons/${encodeURIComponent(name)}` used to put + // "Hacklytics%3A%20Digital%20Bloom" in the path. That string is not the + // stored name, and slugging it produces "hacklytics-3a-20digital-20bloom". + mockFindFirst.mockImplementation(() => undefined); + mockFindMany.mockImplementation((table) => + table === "hackathons" ? [bloom()] : [], + ); + const caller = appRouter.createCaller(createMockCtx("user_a")); + + const res = await caller.hackathon.getById({ + id: encodeURIComponent("Hacklytics: Digital Bloom"), + }); + expect(res.id).toBe(HACK_A); + }); + it("404s on a slug matching no edition", async () => { mockFindFirst.mockImplementation(() => undefined); mockFindMany.mockImplementation((table) => diff --git a/packages/api/src/.internal-tests/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts index 45137d31..4a2b56cb 100644 --- a/packages/api/src/.internal-tests/hackathon-interest.test.ts +++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts @@ -480,5 +480,22 @@ describe("Hackathon interest list", () => { // changes their address stays reachable. expect(rows[0]!.email).toBe("ada@example.com"); }); + + it("counts this edition's interest rows for staff", async () => { + lookups({ hackathon: announced(), isAdmin: true }); + mockSelectRows.mockReturnValue([{ count: 34 }]); + + await expect( + callerFor(ADMIN).hackathon.interestCount({ hackathonId: HACK }), + ).resolves.toBe(34); + }); + + it("hides the count from a caller who is not staff", async () => { + lookups({ hackathon: announced(), isAdmin: false }); + + await expect( + callerFor(VISITOR).hackathon.interestCount({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); }); }); diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts index cde5283d..b4c9ac48 100644 --- a/packages/api/src/routers/hackathon/crud.ts +++ b/packages/api/src/routers/hackathon/crud.ts @@ -30,19 +30,50 @@ const toSlug = (value: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); -// Exact name first, then the slug the portal links with. The slug pass reads -// every edition — there are a handful, and no index covers the normalisation. +/** Must match `decodeHackathonParam` in sites/mainweb/lib/hackathon-slug.ts. */ +function decodeHackathonParam(value: string) { + let current = value; + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(current); + if (next === current) break; + current = next; + } catch { + break; + } + } + return current; +} + +function uniqueStrings(values: string[]) { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +// Exact name first (including a percent-encoded name from old admin links), +// then the slug the portal links with. The slug pass reads every edition — +// there are a handful, and no index covers the normalisation. async function findByNameOrSlug(db: DrizzleDB, value: string) { - const exact = await db.query.hackathons.findFirst({ - where: eq(hackathons.name, value), - }); - if (exact) return exact; + const candidates = uniqueStrings([value, decodeHackathonParam(value)]); + + for (const candidate of candidates) { + const exact = await db.query.hackathons.findFirst({ + where: eq(hackathons.name, candidate), + }); + if (exact) return exact; + } - const slug = toSlug(value); - if (!slug) return undefined; + const slugs = uniqueStrings(candidates.map(toSlug)); + if (slugs.length === 0) return undefined; const all = await db.query.hackathons.findMany(); - return all.find((row) => toSlug(row.name) === slug); + return all.find((row) => slugs.includes(toSlug(row.name))); } export const hackathonCrudRouter = createTRPCRouter({ @@ -99,8 +130,8 @@ export const hackathonCrudRouter = createTRPCRouter({ ).query.hackathons.findMany({ where: and( // Hidden means hidden from the public funnel, not from the people running the - // event. Filtering it for staff too emptied Judging Setup and the judging - // dashboard, which both pick an edition off here. + // event. Filtering it for staff too emptied the judging dashboard, which + // picks an edition off here. adminViewer ? undefined : eq(hackathons.isPublic, true), input.status ? eq(hackathons.status, input.status) : undefined, adminViewer diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index e90da693..931ffe66 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -424,4 +424,17 @@ export const hackathonInterestRouter = createTRPCRouter({ .orderBy(desc(hackathonInterest.createdAt)) .limit(5000); }), + + // How many people asked to be told about THIS edition. The admin card used + // to show currentParticipants (registrations), which is zero while the + // event is announced — so a live interest list looked unlinked. + interestCount: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const [row] = await (ctx.db as DrizzleDB) + .select({ count: sql`count(*)::int` }) + .from(hackathonInterest) + .where(eq(hackathonInterest.hackathonId, input.hackathonId)); + return row?.count ?? 0; + }), }); diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index 6e279cfb..1e547d97 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -101,7 +101,7 @@ export default function AnalyticsPage() {

- Club Events + Operations

Analytics Dashboard diff --git a/sites/mainweb/app/(portal)/admin/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index d2791aeb..881bfaa5 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -89,7 +89,8 @@ export default function AttendeesPage() { Registry

- View and manage attendee registrations for club events. + View and manage attendee check-ins for club meetings. Hackathon + applications live on each edition's dashboard.

{/* Decorative Corner Accent */}
diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index 4d7bdc77..a2e76496 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -6,6 +6,7 @@ import { usePortalContext } from "@/lib/use-portal-context"; import { useParams } from "next/navigation"; import { useState } from "react"; import Link from "next/link"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { ScannerTab } from "@/components/admin/hackathons/ScannerTab"; import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab"; @@ -28,7 +29,10 @@ type Tab = export default function AdminHackathonDashboard() { const { status } = useSession(); const params = useParams(); - const hackathonId = params?.id as string; + const rawId = params?.id; + const hackathonId = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); const [activeTab, setActiveTab] = useState("attendees"); const { data: portalContext, isLoading: portalLoading } = usePortalContext(); @@ -219,6 +223,7 @@ export default function AdminHackathonDashboard() { )} {activeTab === "analytics" && ( diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 7ecc0e06..bd3fc79b 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { Zap } from "lucide-react"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; @@ -13,6 +13,7 @@ import { JudgeMatrixView } from "@/components/admin/judging/JudgeMatrixView"; import { RankingsView } from "@/components/admin/judging/RankingsView"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { JudgeLiveBoard } from "@/components/admin/hackathons/JudgeLiveBoard"; +import { judgingPrepIsCurrent } from "@/lib/judging-prep"; export default function AdminResultsPage() { const { data: session, status } = useSession(); @@ -68,37 +69,105 @@ export default function AdminResultsPage() { message: string | null; error: string | null; }>({ busy: false, message: null, error: null }); + // The edition whose assign call was refused. Rebuild anyway sends force + // for this id, not whatever is selected now — a late CONFLICT from A must + // not rebuild B. + const [assignConflictId, setAssignConflictId] = useState( + null, + ); + const selectedHackathonRef = useRef(selectedHackathon); + selectedHackathonRef.current = selectedHackathon; + const prepGen = useRef(0); const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); const assignJudges = trpc.judge.assignJudgesToProjects.useMutation(); + const isAssignConflict = (error: unknown) => + typeof error === "object" && + error !== null && + "data" in error && + (error as { data?: { code?: string } }).data?.code === "CONFLICT"; + + const stillThisRun = (hackathonId: string, gen: number) => + judgingPrepIsCurrent( + hackathonId, + gen, + selectedHackathonRef.current, + prepGen.current, + ); + + const finishPrepare = async ( + hackathonId: string, + gen: number, + message: string, + ) => { + await utils.judge.getRankings.invalidate({ hackathonId }); + if (!stillThisRun(hackathonId, gen)) return; + await refetchJudgingStatus(); + if (!stillThisRun(hackathonId, gen)) return; + setAssignConflictId(null); + setPrepState({ busy: false, error: null, message }); + }; + const prepareJudging = async () => { - if (!selectedHackathon) return; + const hackathonId = selectedHackathon; + if (!hackathonId) return; + const gen = ++prepGen.current; + setAssignConflictId(null); setPrepState({ busy: true, message: null, error: null }); try { const promoted = await promoteSubmissions.mutateAsync({ - hackathonId: selectedHackathon, + hackathonId, }); const assigned = await assignJudges.mutateAsync({ - hackathonId: selectedHackathon, - }); - await utils.judge.getRankings.invalidate({ - hackathonId: selectedHackathon, + hackathonId, }); - await refetchJudgingStatus(); + if (!stillThisRun(hackathonId, gen)) return; const warning = promoted.queuesNeedRebuild ? " One or more new projects carry a track no active judge covers — fix the track, then run this again." : ""; + await finishPrepare( + hackathonId, + gen, + `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, + ); + } catch (e) { + if (!stillThisRun(hackathonId, gen)) return; + setAssignConflictId(isAssignConflict(e) ? hackathonId : null); setPrepState({ busy: false, - error: null, - message: `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, + message: null, + error: e instanceof Error ? e.message : "Could not prepare judging.", }); + } + }; + + // The server names how many completed slots (or that judging is live) and + // asks for confirmation. This is the only control that sends force: true — + // /admin/setup used to, and now redirects here. + const rebuildQueuesAnyway = async () => { + const hackathonId = assignConflictId; + if (!hackathonId || hackathonId !== selectedHackathonRef.current) return; + const gen = ++prepGen.current; + setPrepState((s) => ({ ...s, busy: true })); + try { + const assigned = await assignJudges.mutateAsync({ + hackathonId, + force: true, + }); + if (!stillThisRun(hackathonId, gen)) return; + await finishPrepare( + hackathonId, + gen, + `Rebuilt queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Completed slots were kept.`, + ); } catch (e) { + if (!stillThisRun(hackathonId, gen)) return; + setAssignConflictId(isAssignConflict(e) ? hackathonId : null); setPrepState({ busy: false, message: null, - error: e instanceof Error ? e.message : "Could not prepare judging.", + error: e instanceof Error ? e.message : "Could not rebuild queues.", }); } }; @@ -120,6 +189,12 @@ export default function AdminResultsPage() { } }, [hackathons, selectedHackathon]); + useEffect(() => { + prepGen.current += 1; + setAssignConflictId(null); + setPrepState({ busy: false, message: null, error: null }); + }, [selectedHackathon]); + const categories = useMemo(() => { if (!rankings?.rankings) return ["ALL"]; const cats = new Set( @@ -338,12 +413,26 @@ export default function AdminResultsPage() {

)} {prepState.error && ( -

- {prepState.error} -

+

{prepState.error}

+ {assignConflictId === selectedHackathon && ( + + )} +
)}
diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx index 3a3f5ed0..a2b53518 100644 --- a/sites/mainweb/app/(portal)/admin/page.tsx +++ b/sites/mainweb/app/(portal)/admin/page.tsx @@ -4,6 +4,7 @@ import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; import { useState } from "react"; +import Link from "next/link"; import { QRCodeModal } from "@/components/portal/QRCodeModal"; import { EventFormModal } from "@/components/portal/EventFormModal"; import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; @@ -252,8 +253,9 @@ export default function AdminPage() { Manager

- Create events, generate QR codes, and track attendance for general - club gatherings. + Club meetings, workshops, and bootcamp sessions. These are not + part of a Hacklytics weekend — that itinerary lives on the + hackathon dashboard.

@@ -283,12 +285,20 @@ export default function AdminPage() { ))} - +
+ + Scan Passes + + +
{eventsLoading ? ( diff --git a/sites/mainweb/app/(portal)/admin/setup/page.tsx b/sites/mainweb/app/(portal)/admin/setup/page.tsx index 2697713b..4d25216f 100644 --- a/sites/mainweb/app/(portal)/admin/setup/page.tsx +++ b/sites/mainweb/app/(portal)/admin/setup/page.tsx @@ -1,370 +1,9 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Zap } from "lucide-react"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { trpcErrorMessage } from "@/lib/trpc-error"; -import { usePortalContext } from "@/lib/use-portal-context"; -import { useRouter } from "next/navigation"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; -import { SetupWizard } from "@/components/admin/setup/SetupWizard"; -import { CreateHackathonStep } from "@/components/admin/setup/CreateHackathonStep"; - -export default function AdminSetupPage() { - const { data: session } = useSession(); - const router = useRouter(); - const [mounted, setMounted] = useState(false); - - // Step state - const [activeStep, setActiveStep] = useState(1); - const [hackathonName, setHackathonName] = useState(""); - const [hackathonTracks, setHackathonTracks] = useState(""); - const [hackathonChallenges, setHackathonChallenges] = useState(""); - // Real dates. These used to be `now` and `now + 24h`, and the submission - // window is derived from the hacking start time — so every deadline the - // product enforces was anchored to whenever the button happened to be pressed. - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [hackingStartTime, setHackingStartTime] = useState(""); - const [selectedHackathonId, setSelectedHackathonId] = useState( - null, - ); - - // Status tracking - const [projectsSynced, setProjectsSynced] = useState(false); - const [judgesAssigned, setJudgesAssigned] = useState(false); - - // Admin check - const { data: portalContext } = usePortalContext(); - - const { data: hackathons, refetch: refetchHackathons } = - trpc.hackathon.list.useQuery( - {}, - { - enabled: !!session && !!portalContext?.isAdmin, - }, - ); - - // Mutations - const createHackathon = trpc.hackathon.create.useMutation({ - onSuccess: (data) => { - setSelectedHackathonId(data?.id ?? null); - setActiveStep(2); - refetchHackathons(); - }, - }); - - const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation({ - onSuccess: (data) => { - // Nothing to judge means the step is not done, however cleanly the - // request succeeded — moving on would hand the assigner an empty list. - if (data.total === 0) return; - setProjectsSynced(true); - setActiveStep(3); - }, - }); - - const assignJudges = trpc.judge.assignJudgesToProjects.useMutation({ - onSuccess: () => { - setJudgesAssigned(true); - }, - }); - - useEffect(() => setMounted(true), []); - - if (!mounted) return null; - - const steps = [ - { num: 1, label: "Create Hackathon", done: !!selectedHackathonId }, - { num: 2, label: "Sync Submissions", done: projectsSynced }, - { num: 3, label: "Assign Judges", done: judgesAssigned }, - ]; - - return ( - <> -
-
-

- Hackathon Hub -

-

- Judging Setup -

-

- Everything comes from the portal — nothing to upload -

-
- - {/* Progress Steps */} - - - {/* Step 1: Create Hackathon */} - {activeStep === 1 && ( - { - if (!hackathonName.trim()) return; - - // Parse tracks and challenges - const parsedTracks = hackathonTracks - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const parsedChallenges = hackathonChallenges - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - - if (!startDate || !endDate) return; - - createHackathon.mutate({ - name: hackathonName.trim(), - startDate: new Date(startDate), - endDate: new Date(endDate), - hackingStartTime: hackingStartTime - ? new Date(hackingStartTime) - : undefined, - tracks: parsedTracks.length > 0 ? parsedTracks : undefined, - challenges: - parsedChallenges.length > 0 ? parsedChallenges : undefined, - }); - }} - /> - )} - - {/* Step 2: Sync submitted projects into judging */} - {activeStep === 2 && ( - -

- Sync Submitted Projects -

-

- Every submitted project becomes a judgeable entry with its own - table number, carrying the tracks and challenges the team picked. - Safe to run again as late submissions land — projects already - synced are left alone. -

- - - - {promoteSubmissions.error && ( -
-

- {trpcErrorMessage( - promoteSubmissions.error, - "Could not sync submissions.", - )} -

-
- )} - - {promoteSubmissions.data && - (promoteSubmissions.data.total === 0 ? ( -
-

- No submitted projects yet. Teams submit from /submit — come - back once the deadline has passed. -

-
- ) : ( -
-
-

- {promoteSubmissions.data.created} newly synced |{" "} - {promoteSubmissions.data.alreadyPresent} already in - judging | {promoteSubmissions.data.total} total -

-
- {/* Queues are a snapshot, so promotion adds late projects to - the queues that already exist — appending, because a - rebuild reorders every queue mid-judging. */} - {promoteSubmissions.data.queueRowsAdded > 0 && ( -
-

- Added to existing judge queues in{" "} - {promoteSubmissions.data.queueRowsAdded} slot(s). No - re-assign needed — nobody's current queue was reordered. -

-
- )} - {promoteSubmissions.data.queuesNeedRebuild && ( -
-

- The newly synced projects reached no judge — their - track is one no active judge covers. Set a judge to that - track, or re-run Assign Judges. -

-
- )} -
- ))} -
- )} - - {/* Step 3: Auto-Assign Judges */} - {activeStep === 3 && ( - -

- Auto-Assign Judges to Projects -

-

- Main track judges get 3-9 projects. Special label judges get all - matching projects (randomized). -

- -
-

- Judges sign themselves up at{" "} - /judge/register. Approve - their applications under the Judges tab of this hackathon before - assigning — only approved judges get a queue. -

-
- - - - {assignJudges.error && ( -
-

- {trpcErrorMessage( - assignJudges.error, - "Could not assign judges.", - )} -

- {/* The server refuses a rebuild that would disturb live or - completed judging, and reports how much is at stake. Only - once the admin has read that number is overriding offered — - they cannot know the count without the round trip. */} - {assignJudges.error.data?.code === "CONFLICT" && ( - - )} -
- )} - - {assignJudges.data && ( -
-
-

- Assigned {assignJudges.data.totalJudges} judges -

-
-
- - - - - - - - - - {assignJudges.data.assignments.map((a, i) => ( - - - - - - ))} - -
JudgeTrack - Projects -
- {a.judgeName || "Unknown"} - - {a.track || "General"} - - {a.assignedCount} -
-
-
- )} - - {judgesAssigned && ( -
-

- Setup complete - all judges assigned. -

- -
- )} -
- )} -
- - ); +import { redirect } from "next/navigation"; + +/** + * Judging Setup used to create a second hackathon record. Editions already + * exist on the hackathon side of the portal; queue prep lives on /admin/judging. + */ +export default function AdminSetupRedirect() { + redirect("/admin/judging"); } diff --git a/sites/mainweb/app/(portal)/admin/staff/page.tsx b/sites/mainweb/app/(portal)/admin/staff/page.tsx index b7f2f3eb..861fe487 100644 --- a/sites/mainweb/app/(portal)/admin/staff/page.tsx +++ b/sites/mainweb/app/(portal)/admin/staff/page.tsx @@ -20,7 +20,7 @@ const ROLES = [ { id: "volunteer" as const, label: "Volunteer", - hint: "Badge scanning at /scan. Nothing else — the admin pages refuse them.", + hint: "Hackathon badge scanning at /scan and club pass scanning at /scan/club. Nothing else — the admin pages refuse them.", }, { id: "moderator" as const, diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index d6243041..73617ba5 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -8,6 +8,7 @@ import { trpc } from "@/lib/trpc"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { QRScannerModal } from "@/components/portal/QRScannerModal"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; type Project = { id: string; @@ -49,7 +50,10 @@ export default function JudgeHackathonPage() { * procedures take a uuid, so a by-name URL would fail input validation. * Resolve it first when it is not already an id. */ - const routeParam = params.id as string; + const rawId = params.id; + const routeParam = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( routeParam, diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx index 439e9fff..d2076a26 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx @@ -15,6 +15,7 @@ import { ProjectsTab } from "@/components/hackathon/ProjectsTab"; import { ResultsTab } from "@/components/hackathon/ResultsTab"; import { TeamsTab } from "@/components/hackathon/TeamsTab"; import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; function formatDate(d: Date | string) { return new Date(d).toLocaleDateString("en-US", { @@ -124,7 +125,10 @@ export default function HackathonDetailPage() { const router = useRouter(); const params = useParams(); const searchParams = useSearchParams(); - const hackathonId = params.id as string; + const rawId = params.id; + const hackathonId = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); const tabParam = searchParams.get("tab") as TabType | null; const [tab, setTab] = useState( @@ -192,7 +196,7 @@ export default function HackathonDetailPage() { /> - All Events + All Hackathons {/* Header Card */} diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx index 573e56cc..065f07f3 100644 --- a/sites/mainweb/app/(portal)/hacklytics/page.tsx +++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx @@ -1,11 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; -import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { hackathonSlug } from "@/lib/hackathon-slug"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { InterestForm } from "@/components/hackathon/InterestForm"; /** * The public landing page for an edition that has been announced but is not yet @@ -16,16 +15,12 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen"; * page. A signed-out stranger is the entire audience, so everything above the * form renders without a session and the sidebar is suppressed for it in * PortalWrapper. + * + * The form writes against this edition's id (from getUpcoming), not a + * singleton. The same component is mounted on the edition page so joining + * from either place lands on the same row. */ -const EXPERIENCE_OPTIONS = [ - { value: "first", label: "This would be my first" }, - { value: "one_or_two", label: "I've done one or two" }, - { value: "three_plus", label: "I've done three or more" }, -] as const; - -type Experience = (typeof EXPERIENCE_OPTIONS)[number]["value"]; - /** * Dates are rendered from a fixed locale and an explicit time zone rather than * the viewer's. The event happens in Atlanta; showing somebody in Singapore @@ -58,85 +53,8 @@ const formatDeadline = (deadline: Date) => timeZone: "America/New_York", }); -function Field({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( - - ); -} - -const inputClass = - "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-ui"; - export default function HacklyticsPage() { - const { data: session, status: sessionStatus } = useSession(); - const utils = trpc.useUtils(); - const upcoming = trpc.hackathon.getUpcoming.useQuery(); - const hackathonId = upcoming.data?.id; - - const mine = trpc.hackathon.myInterest.useQuery( - { hackathonId: hackathonId ?? "" }, - { enabled: !!hackathonId && !!session }, - ); - - const [school, setSchool] = useState(""); - const [country, setCountry] = useState(""); - const [graduationYear, setGraduationYear] = useState(""); - const [experience, setExperience] = useState(""); - const [editing, setEditing] = useState(false); - const [error, setError] = useState(""); - - // Prefill from an existing entry so "edit" starts from what they told us, - // rather than making them retype it to change one field. - useEffect(() => { - if (!mine.data) return; - setSchool(mine.data.school ?? ""); - setCountry(mine.data.country ?? ""); - setGraduationYear( - mine.data.graduationYear ? String(mine.data.graduationYear) : "", - ); - setExperience((mine.data.experience as Experience) ?? ""); - }, [mine.data]); - - const refresh = async () => { - if (hackathonId) await utils.hackathon.myInterest.invalidate({ hackathonId }); - }; - - const join = trpc.hackathon.registerInterest.useMutation({ - onSuccess: async () => { - setError(""); - setEditing(false); - await refresh(); - }, - onError: (e) => setError(e.message), - }); - - const leave = trpc.hackathon.withdrawInterest.useMutation({ - onSuccess: async () => { - setError(""); - setEditing(false); - await refresh(); - }, - onError: (e) => setError(e.message), - }); if (upcoming.isPending) return ; @@ -182,9 +100,6 @@ export default function HacklyticsPage() { // working past the moment registration opens — before, it collected the // interest list; after, it points at the registration itself. const registrationOpen = event.registrationOpen; - const onList = !!mine.data; - const showForm = !onList || editing; - const busy = join.isPending || leave.isPending; return (
@@ -259,174 +174,11 @@ export default function HacklyticsPage() { Register now - ) : sessionStatus === "loading" ? ( -

Checking sign-in…

- ) : !session ? ( -
-

- Get told the moment it opens -

-

- Sign in so we have a verified address to reach you at. Google, - GitHub, or a code sent to any email — no account needed - beforehand, and it works wherever you are in the world. -

- - Sign in to join the list - -
) : ( -
-
-

- {onList ? "You're on the list" : "Join the interest list"} -

-

- {onList - ? `We'll email ${session.user?.email} the moment registration opens.` - : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."} -

-
- - {showForm ? ( -
{ - e.preventDefault(); - if (!hackathonId) return; - const parsedYear = graduationYear.trim() - ? Number(graduationYear) - : null; - if ( - parsedYear !== null && - (!Number.isInteger(parsedYear) || - parsedYear < 1900 || - parsedYear > 2100) - ) { - setError("That graduation year does not look right."); - return; - } - join.mutate({ - hackathonId, - school: school.trim() || undefined, - country: country.trim() || undefined, - graduationYear: parsedYear, - experience: experience || undefined, - }); - }} - > - - setSchool(e.target.value)} - placeholder="Georgia Institute of Technology" - maxLength={200} - /> - - - - setCountry(e.target.value)} - placeholder="United States" - maxLength={100} - /> - - - - setGraduationYear(e.target.value)} - placeholder="2029" - inputMode="numeric" - /> - - - - - - - {error ? ( -

- {error} -

- ) : null} - -
- - {onList ? ( - - ) : null} -
-
- ) : ( -
- - -
- )} - - {error && !showForm ? ( -

{error}

- ) : null} -
+ )} diff --git a/sites/mainweb/app/(portal)/scan/club/page.tsx b/sites/mainweb/app/(portal)/scan/club/page.tsx new file mode 100644 index 00000000..a4728ee2 --- /dev/null +++ b/sites/mainweb/app/(portal)/scan/club/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { usePortalContext } from "@/lib/use-portal-context"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { ScanAccess } from "@/components/portal/ScanAccess"; +import { ClubScannerTab } from "@/components/portal/ClubScannerTab"; + +/** + * Club meeting check-in. + * + * Scans a member pass, not a hackathon badge. Kept off /scan so club + * gatherings are not a mode of the hackathon desk. + */ +export default function ClubScanPage() { + const { data: portalContext } = usePortalContext(); + const [clubEventId, setClubEventId] = useState(""); + + const { data: clubEvents } = trpc.events.list.useQuery(undefined, { + enabled: !!portalContext?.isScanner, + }); + + return ( + +
+
+

+ Club +

+

+ Club Check-In +

+

+ Scan member passes into a club meeting. Not part of a hackathon + weekend. +

+
+ +
+ + +
+ + {clubEventId ? ( + + ) : ( + +

+ Choose a club event above to start scanning. +

+
+ )} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/scan/page.tsx b/sites/mainweb/app/(portal)/scan/page.tsx index ac4cce48..e6d035cf 100644 --- a/sites/mainweb/app/(portal)/scan/page.tsx +++ b/sites/mainweb/app/(portal)/scan/page.tsx @@ -1,176 +1,76 @@ "use client"; import { useState } from "react"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { ScanAccess } from "@/components/portal/ScanAccess"; import { ScannerTab } from "@/components/admin/hackathons/ScannerTab"; -import { ClubScannerTab } from "@/components/portal/ClubScannerTab"; /** - * The check-in desk. + * Hackathon badge check-in. * - * Deliberately outside the /admin segment. AdminLayout gates on - * `portalContext.isAdmin`, which rejects volunteers by design — so the only - * scanning UI in the product lived behind a door the scanner tier could not - * open. The alternative was relaxing that gate and then re-scoping every admin - * tab behind it, which is a much wider blast radius for the same outcome. - * - * Full staff can use this too: isScanner accepts any active admins row, so a - * volunteer and an organiser get the same screen. + * Club meetings are a different desk (/scan/club). Volunteers are not admins, + * so this stays outside /admin; mixing both modes here is what put club + * events inside the hackathon. */ export default function ScanPage() { - const { status } = useSession(); - const router = useRouter(); - const { data: portalContext, isLoading } = usePortalContext(); - const [mode, setMode] = useState<"hackathon" | "club">("hackathon"); + const { data: portalContext } = usePortalContext(); const [hackathonId, setHackathonId] = useState(""); - const [clubEventId, setClubEventId] = useState(""); - // Public procedures, so a volunteer can read them. The listAll variants are - // isAdmin and would reject exactly the people this page exists for. const { data: hackathons } = trpc.hackathon.list.useQuery( {}, { enabled: !!portalContext?.isScanner }, ); - const { data: clubEvents } = trpc.events.list.useQuery(undefined, { - enabled: !!portalContext?.isScanner, - }); - - if (status === "loading" || isLoading) { - return ; - } - if (status === "unauthenticated") { - router.push("/login"); - return null; - } - - if (!portalContext?.isScanner) { - return ( -
- -

- Not a Scanner + return ( + +
+
+

+ Hackathon +

+

+ Check-In Desk

-

- This is the event check-in desk. Ask an organiser to add you as - event staff. +

+ Scan participant badges into this edition's workshops, meals, + and ceremonies.

- - Back to Dashboard - - -
- ); - } +
- return ( -
-
-

- Check-In Desk -

-

- Scan attendee badges into an event. -

-
- - {/* The two halves scan opposite things: a hackathon badge encodes the - participant, a club pass encodes the member. */} -
- {(["hackathon", "club"] as const).map((m) => ( - - ))} -
- - {mode === "hackathon" ? ( - <> -
- - -
- - {hackathonId ? ( - - ) : ( - -

- Choose a hackathon above to start scanning. -

-
- )} - - ) : ( - <> -
- - -
+ Hackathon + + +
- {clubEventId ? ( - - ) : ( - -

- Choose a club event above to start scanning. -

-
- )} - - )} -

+ {hackathonId ? ( + + ) : ( + +

+ Choose a hackathon above to start scanning. +

+
+ )} + + ); } diff --git a/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx index d1f2e5ad..459a333e 100644 --- a/sites/mainweb/app/events/page.tsx +++ b/sites/mainweb/app/events/page.tsx @@ -3,6 +3,7 @@ import Footer from "@/components/Footer"; import Section from "@/components/Section"; import { db, events } from "@query/db"; import { gte } from "drizzle-orm"; +import Link from "next/link"; /** * The club's upcoming events. @@ -64,18 +65,19 @@ export default async function EventsPage() {

- Events + Club Events

- Track upcoming hackathons, workshops, and community gatherings. + Upcoming DSGT meetings, workshops, and community gatherings. + Hacklytics has its own page.

- Upcoming Events + Upcoming Club Events

{upcoming.length === 0 ? (

- No upcoming events scheduled. Check back soon! + No upcoming club events scheduled. Check back soon!

) : (
    @@ -109,6 +111,16 @@ export default async function EventsPage() {
)}
+

+ Looking for Hacklytics?{" "} + + Go to the hackathon page + + . +

diff --git a/sites/mainweb/app/team/page.tsx b/sites/mainweb/app/team/page.tsx index d81beb16..7e9c9c2e 100644 --- a/sites/mainweb/app/team/page.tsx +++ b/sites/mainweb/app/team/page.tsx @@ -6,19 +6,9 @@ import Navbar from "@/components/Navbar"; import Section from "@/components/Section"; import TeamCard from "@/components/TeamCard"; -// Asset imports -import President from "@/assets/images/2025/aditi.jpg"; -import ViceP from "@/assets/images/2025/nitika.jpg"; -import Logistics1 from "@/assets/images/2025/alysha.jpg"; -import Logistics2 from "@/assets/images/2025/diya.jpeg"; -import Events from "@/assets/images/2025/aryan.jpeg"; -import Marketing from "@/assets/images/2025/smera.png"; -import Tech from "@/assets/images/2025/aamogh.png"; -import Content1 from "@/assets/images/2025/anushka.jpg"; -import Content2 from "@/assets/images/2025/glenne.png"; - -import External2 from "@/assets/images/2025/vidhi.jpeg"; -import Project from "@/assets/images/2025/anika.jpg"; +// Asset imports — reuse in-repo photos only +import Aamogh from "@/assets/images/2025/aamogh.png"; +import Diya from "@/assets/images/2025/diya.jpeg"; import Advisor from "@/assets/images/2025/jake.png"; import IDEaS from "@/assets/images/2025/ideas.png"; @@ -50,7 +40,7 @@ const Team = () => { {/* Simplified Header Section */}
- Executive Board 2025-2026 + Executive Board 2026-2027

Meet the{" "} @@ -79,109 +69,87 @@ const Team = () => { - Aditi oversees all operations of DSGT, leading the executive board - and coordinating with faculty and industry partners to shape the - future of data science at GT. + Aamogh oversees DSGT operations, leading the executive board and + coordinating with faculty and industry partners to shape data + science at Georgia Tech. - Nikita leads Hacklytics, DSGT's flagship - datathon. She manages corporate sponsorships, event organization, - and networking initiatives. + Diya serves as Vice President and Co-Director of{" "} + Hacklytics, DSGT's flagship datathon, managing + event organization, corporate sponsorships, and member-facing + programming. - - Alysha coordinates logistics for club events and Hacklytics, - managing smooth operations and collaborating with external - sponsors. + + Nitya directs social media, graphic design, and outreach + strategies to increase engagement with DSGT both on and off + campus. - - Diya focuses on event management, Hacklytics logistic - coordination, and logistics operations, ensuring a seamless - experience for members and corporate partners. + + Samantha oversees project logistics, managing the project portal + and setting up research opportunities with professors and + industry pros. - Aamogh leads the Tech Team, managing frontend and backend systems - for DSGT's digital infrastructure, including the Membership - Portal. + Aishi develops strategic partnerships and manages sponsor + communications to support club initiatives and industry + collaboration. - Smera directs social media, graphic design, and outreach - strategies to increase engagement with DSGT both on and off - campus. + Vishal manages sponsor communications and builds industry + partnerships that support DSGT initiatives. - - Aryan manages room bookings, catering, and volunteer coordination, - ensuring technical workshops and socials execute perfectly. + + Minjee coordinates logistics for club events and Hacklytics, + managing smooth operations and collaborating with external + sponsors. - Vidhi develops strategic partnerships and manages sponsor - communications to support club initiatives and industry - collaboration. + Francisco manages event logistics so club programs and Hacklytics + run on schedule. - - Anushka leads the Content team in managing Bootcamp and Udemy + + Yashika manages room bookings, catering, and volunteer + coordination, ensuring technical workshops and socials execute + perfectly. + + + + Sahith leads the Content team in managing Bootcamp and Udemy courses, helping members build professional ML projects. - - Glenne oversees Bootcamp workshops, ensuring members learn core + + Victor oversees Bootcamp workshops, ensuring members learn core data science skills and complete polished, industry-ready projects. - - Anika oversees project logistics, managing the project portal and - setting up research opportunities with professors and industry - pros. - - Jacob is an Assistant Professor in the School of Computer Science and serves as a faculty advisor to DS@GT. His research focuses on diff --git a/sites/mainweb/components/TeamCard/index.tsx b/sites/mainweb/components/TeamCard/index.tsx index 268d560e..c6756bcd 100644 --- a/sites/mainweb/components/TeamCard/index.tsx +++ b/sites/mainweb/components/TeamCard/index.tsx @@ -5,17 +5,29 @@ import type { StaticImageData } from "next/image"; import Image from "next/image"; interface TeamCardProps extends HTMLAttributes { - img: string | StaticImageData; + img?: string | StaticImageData; name: string; title: string; + href?: string; zoom?: boolean; children?: ReactNode; } +function initialsFor(name: string) { + return name + .split(/\s+/) + .filter((part) => /^[A-Za-z]/.test(part)) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); +} + export default function TeamCard({ img, name, title, + href, zoom, children, ...rest @@ -36,14 +48,23 @@ export default function TeamCard({ height: 140, }} > - {name} + {img ? ( + {name} + ) : ( + + )} {/* Updated overlay glow to #00A8A8 */}
@@ -51,15 +72,26 @@ export default function TeamCard({ {/* Content */}
{/* Title Tag - Updated to #00A8A8 */} -
-

+
+

{title}

{/* Name - High contrast white */}

- {name} + {href ? ( + + {name} + + ) : ( + name + )}

{/* Description */} diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx index 5d56c426..a8143327 100644 --- a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx @@ -61,7 +61,7 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { const [sending, setSending] = useState(false); const [progress, setProgress] = useState(null); const [error, setError] = useState(null); - const [showInterest, setShowInterest] = useState(false); + const [showInterest, setShowInterest] = useState(true); // The four questions the public form collects were shown to no organiser at // all — the data was gathered and then only ever read as a recipient count. diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx index 197cb171..ec25f2aa 100644 --- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx @@ -40,13 +40,16 @@ import { AcceptanceWaves } from "./AcceptanceWaves"; import { AttendeeStats } from "./AttendeeStats"; import { statusColors, statusIcon } from "./attendee-status"; import type { RegistrationStatus } from "./attendee-status"; +import type { HackathonStatus } from "./constants"; export function AttendeesTab({ hackathonId, hackathonName, + status, }: { hackathonId: string; hackathonName: string; + status: HackathonStatus; }) { const utils = trpc.useUtils(); @@ -79,6 +82,18 @@ export function AttendeesTab({ // Stat tiles come from the aggregate, not from counting the rows on screen. // Counting a page would report "12 pending" when 400 are. const { data: analytics } = trpc.hackathon.analytics.useQuery({ hackathonId }); + // Parent already fetched getById with the route slug; this UUID query + // misses that cache. Until it returns, fall back to the status the + // dashboard already has so announced editions don't flash the empty queue. + const { data: hackathon } = trpc.hackathon.getById.useQuery({ + id: hackathonId, + }); + const announced = (hackathon?.status ?? status) === "announced"; + const { data: interestRows, isLoading: interestLoading } = + trpc.hackathon.listInterest.useQuery( + { hackathonId }, + { enabled: announced }, + ); const attendees = data?.attendees; @@ -391,8 +406,65 @@ export function AttendeesTab({ return (
- + + {announced ? ( + +

+ Interest list +

+

+ People who asked to be told when{" "} + {hackathonName}{" "} + opens. This is not an application queue — opening registration lets + them apply. +

+ {interestLoading ? ( +

+ Loading this edition's list… +

+ ) : (interestRows?.length ?? 0) === 0 ? ( +

+ Nobody has joined this edition yet. The public form writes here. +

+ ) : ( +
+ + + + + + + + + + + + + {interestRows?.map((row) => ( + + + + + + + + + ))} + +
NameEmailSchoolCountryGradExperience
+ {row.name ?? "—"} + {row.email}{row.school ?? "—"}{row.country ?? "—"} + {row.graduationYear ?? "—"} + {row.experience ?? "—"}
+
+ )} +
+ ) : ( + <> )} + + )}
); } diff --git a/sites/mainweb/components/admin/hackathons/EventsTab.tsx b/sites/mainweb/components/admin/hackathons/EventsTab.tsx index 134bfd1d..e009818a 100644 --- a/sites/mainweb/components/admin/hackathons/EventsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/EventsTab.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { trpcErrorMessage } from "@/lib/trpc-error"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; @@ -184,11 +185,16 @@ export function EventsTab({ hackathonId }: { hackathonId: string }) {

- Events + Weekend Itinerary

- {events?.length || 0} event{events?.length !== 1 ? "s" : ""}{" "} - scheduled + {events?.length || 0} session + {events?.length !== 1 ? "s" : ""} this edition — workshops, meals, + ceremonies.{" "} + + Club meetings are on Club Hub + + .

- No events yet + No itinerary yet

- Create workshops, meals, ceremonies and more. + Add workshops, meals, and ceremonies for this weekend. Club + meetings belong on Club Hub, not here.

) : ( diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx index 8c74e81d..1ef9ef01 100644 --- a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx +++ b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx @@ -4,6 +4,7 @@ import React from "react"; import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { adminHackathonPath } from "@/lib/hackathon-slug"; import type { HackathonStatus } from "@/components/admin/hackathons/constants"; export function HackathonCard({ @@ -30,6 +31,10 @@ export function HackathonCard({ const { data: events, isLoading: eventsLoading } = trpc.hackathon.getEvents.useQuery({ hackathonId: hackathon.id }); + const { data: interestCount } = trpc.hackathon.interestCount.useQuery( + { hackathonId: hackathon.id }, + { enabled: hackathon.status === "announced" }, + ); const updateMutation = trpc.hackathon.update.useMutation({ onSuccess: () => { @@ -54,7 +59,7 @@ export function HackathonCard({

{hackathon.name} @@ -89,9 +94,12 @@ export function HackathonCard({

- Participants + {hackathon.status === "announced" + ? "Interest list" + : "Participants"}

- {hackathon.maxParticipants && ( + {hackathon.status !== "announced" && + hackathon.maxParticipants && ( {Math.round( (hackathon.currentParticipants / @@ -103,16 +111,19 @@ export function HackathonCard({ )}

- {hackathon.currentParticipants} - {hackathon.maxParticipants - ? ` / ${hackathon.maxParticipants}` - : " registered"} + {hackathon.status === "announced" + ? `${interestCount ?? "…"} interested` + : `${hackathon.currentParticipants}${ + hackathon.maxParticipants + ? ` / ${hackathon.maxParticipants}` + : " registered" + }`}

{/* Registration Progress Bar */} - {hackathon.maxParticipants && ( + {hackathon.status !== "announced" && hackathon.maxParticipants && (
{events && events.length > 0 && ( - Manage Events ({events.length}) → + Manage Itinerary ({events.length}) → )}
@@ -148,8 +159,8 @@ export function HackathonCard({
) : !events || events.length === 0 ? (
- No events scheduled for this hackathon yet. Click Dashboard to - add some! + No weekend itinerary yet. Open the dashboard to add workshops + and meals for this edition — club meetings stay on Club Hub.
) : (
@@ -263,7 +274,7 @@ export function HackathonCard({ {events.length > 4 && (
+ {events.length - 4} more scheduled events (Click to view @@ -343,7 +354,7 @@ export function HackathonCard({ Dashboard diff --git a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx index 1a58c413..5eaaf3f6 100644 --- a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx +++ b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx @@ -3,18 +3,28 @@ import React from "react"; import { trpc } from "@/lib/trpc"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; -import { toInputDate } from "@/components/admin/hackathons/constants"; +import { + toInputDate, + type HackathonStatus, +} from "@/components/admin/hackathons/constants"; import { Clock } from "lucide-react"; /** * Registration status + deadline controls for one hackathon. * Extracted from AttendeesTab; owns its own mutation so the parent stays lean. */ -export function RegistrationControls({ hackathonId }: { hackathonId: string }) { +export function RegistrationControls({ + hackathonId, + status, +}: { + hackathonId: string; + status: HackathonStatus; +}) { const utils = trpc.useUtils(); const { data: hackathon } = trpc.hackathon.getById.useQuery({ id: hackathonId, }); + const editionStatus = hackathon?.status ?? status; const updateHackathon = trpc.hackathon.update.useMutation({ onSuccess: () => { @@ -29,7 +39,7 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { const deadlinePassed = !!regDeadline && regDeadline < new Date(); const registrationOpen = - hackathon?.status === "open" || hackathon?.status === "in_progress"; + editionStatus === "open" || editionStatus === "in_progress"; /** * The interest list exists for one moment — this one — and nothing used to @@ -40,7 +50,16 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { */ const interestStatus = trpc.hackathon.registrationOpenEmailStatus.useQuery( { hackathonId }, - { enabled: registrationOpen }, + { + enabled: + editionStatus === "announced" || + editionStatus === "open" || + editionStatus === "in_progress", + }, + ); + const { data: interestCount } = trpc.hackathon.interestCount.useQuery( + { hackathonId }, + { enabled: editionStatus === "announced" }, ); const [notifyError, setNotifyError] = React.useState(null); @@ -72,9 +91,9 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { Status: - {hackathon?.status || "unknown"} + {editionStatus || "unknown"}
@@ -91,7 +110,7 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) {
- {hackathon?.status !== "open" && ( + {editionStatus !== "open" && (
+ {editionStatus === "announced" && ( +
+

+ Interest list for this edition +

+

+ {interestCount === undefined + ? "Checking this edition's list…" + : interestCount === 0 + ? "Nobody has joined yet. The public form writes to this hackathon." + : `${interestCount} ${interestCount === 1 ? "person is" : "people are"} on this edition's list. Applications stay empty until you open registration. The Email tab has the roster.`} +

+
+ )} + {registrationOpen && (interestStatus.data?.total ?? 0) > 0 && (
diff --git a/sites/mainweb/components/admin/setup/CreateHackathonStep.tsx b/sites/mainweb/components/admin/setup/CreateHackathonStep.tsx deleted file mode 100644 index 35892634..00000000 --- a/sites/mainweb/components/admin/setup/CreateHackathonStep.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import React from "react"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; - -type Hackathon = { - id: string; - name: string; -}; - -type CreateHackathonStepProps = { - hackathons: Hackathon[]; - selectedHackathonId: string | null; - setSelectedHackathonId: (id: string | null) => void; - setActiveStep: (step: number) => void; - hackathonName: string; - setHackathonName: (name: string) => void; - hackathonTracks: string; - setHackathonTracks: (tracks: string) => void; - hackathonChallenges: string; - setHackathonChallenges: (challenges: string) => void; - /** `datetime-local` strings. The submission window is derived from the - * hacking start time, so a wrong value here shifts every deadline. */ - startDate: string; - setStartDate: (value: string) => void; - endDate: string; - setEndDate: (value: string) => void; - hackingStartTime: string; - setHackingStartTime: (value: string) => void; - createHackathonPending: boolean; - /** Server-side refusal, e.g. the CONFLICT raised when a hackathon with this - * name already exists. Without somewhere to render it the button simply - * does nothing. */ - createHackathonError?: string | null; - onCreateHackathon: () => void; -}; - -export function CreateHackathonStep({ - hackathons, - selectedHackathonId, - setSelectedHackathonId, - setActiveStep, - hackathonName, - setHackathonName, - hackathonTracks, - setHackathonTracks, - hackathonChallenges, - setHackathonChallenges, - startDate, - setStartDate, - endDate, - setEndDate, - hackingStartTime, - setHackingStartTime, - createHackathonPending, - createHackathonError, - onCreateHackathon, -}: CreateHackathonStepProps) { - return ( - -

- Create Hackathon -

- - {/* Existing hackathons */} - {hackathons && hackathons.length > 0 && ( -
-

- Or select an existing event: -

-
- {hackathons.map((h) => ( - - ))} -
-
-
- - or create new - -
-
-
- )} - -
- setHackathonName(e.target.value)} - className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors" - /> - setHackathonTracks(e.target.value)} - className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors" - /> - setHackathonChallenges(e.target.value)} - className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors" - /> - {/* Real dates, not "now" and "now + 24h". - createHackathon used to stamp both from the moment the button was - pressed, and the whole submission window is derived from the hacking - start time — so every deadline the product enforces was anchored to - an accident. */} -
- - - -
- - {createHackathonError && ( -

- {createHackathonError} -

- )} -
- - ); -} diff --git a/sites/mainweb/components/admin/setup/SetupWizard.tsx b/sites/mainweb/components/admin/setup/SetupWizard.tsx deleted file mode 100644 index 07eb5dc4..00000000 --- a/sites/mainweb/components/admin/setup/SetupWizard.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import React from "react"; - -type StepProps = { - activeStep: number; - setActiveStep: (step: number) => void; - steps: { num: number; label: string; done: boolean }[]; -}; - -export function SetupWizard({ activeStep, setActiveStep, steps }: StepProps) { - return ( -
- {steps.map((s, i) => ( - - - {i < steps.length - 1 && ( -
- )} - - ))} -
- ); -} diff --git a/sites/mainweb/components/hackathon/InfoTab.tsx b/sites/mainweb/components/hackathon/InfoTab.tsx index 889b96d8..e52a7cb7 100644 --- a/sites/mainweb/components/hackathon/InfoTab.tsx +++ b/sites/mainweb/components/hackathon/InfoTab.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useEffect, useState } from "react"; -import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { formatPhoneAsTyped, @@ -30,6 +29,8 @@ import { MAJORS, } from "@/components/hackathon/constants"; import type { ShirtSize, LevelOfStudy } from "@/components/hackathon/constants"; +import { InterestForm } from "@/components/hackathon/InterestForm"; +import { hackathonSlug } from "@/lib/hackathon-slug"; type RegistrationStep = 0 | 1 | 2 | 3; @@ -464,22 +465,13 @@ export function InfoTab({
) : hackathon.status === "announced" ? ( - /* Announced is not closed — the lock below reads as "you missed it". */ -
-

- Registration Opens Soon -

-

- Applications aren't open yet. Join the interest list and - we'll email you the moment they are. -

- - Join the Interest List - -
+ /* Announced is not closed — the lock below reads as "you missed it". + Join against THIS edition's id, not a bounce to /hacklytics which + follows whichever event getUpcoming picks. */ + ) : isFull ? (
+ + {label} + + {children} + {hint ? ( + + {hint} + + ) : null} + + ); +} + +const inputClass = + "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-ui"; + +/** + * Join / leave the interest list for one edition. + * + * The row is keyed by hackathon id, not by a singleton funnel. /hacklytics + * and this edition's Info tab both have to write against the same UUID or + * staff looking at Digital Bloom see an empty list next to a form that + * actually landed on a different event. + */ +export function InterestForm({ + hackathonId, + callbackPath, +}: { + hackathonId: string; + callbackPath: string; +}) { + const { data: session, status: sessionStatus } = useSession(); + const utils = trpc.useUtils(); + + const mine = trpc.hackathon.myInterest.useQuery( + { hackathonId }, + { enabled: !!hackathonId && !!session }, + ); + + const [school, setSchool] = useState(""); + const [country, setCountry] = useState(""); + const [graduationYear, setGraduationYear] = useState(""); + const [experience, setExperience] = useState(""); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!mine.data) return; + setSchool(mine.data.school ?? ""); + setCountry(mine.data.country ?? ""); + setGraduationYear( + mine.data.graduationYear ? String(mine.data.graduationYear) : "", + ); + setExperience((mine.data.experience as Experience) ?? ""); + }, [mine.data]); + + const refresh = async () => { + await Promise.all([ + utils.hackathon.myInterest.invalidate({ hackathonId }), + utils.hackathon.interestCount.invalidate({ hackathonId }), + utils.hackathon.listInterest.invalidate({ hackathonId }), + ]); + }; + + const join = trpc.hackathon.registerInterest.useMutation({ + onSuccess: async () => { + setError(""); + setEditing(false); + await refresh(); + }, + onError: (e) => setError(e.message), + }); + + const leave = trpc.hackathon.withdrawInterest.useMutation({ + onSuccess: async () => { + setError(""); + setEditing(false); + await refresh(); + }, + onError: (e) => setError(e.message), + }); + + const onList = !!mine.data; + const showForm = !onList || editing; + const busy = join.isPending || leave.isPending; + + if (sessionStatus === "loading") { + return

Checking sign-in…

; + } + + if (!session) { + return ( +
+

+ Get told the moment it opens +

+

+ Sign in so we have a verified address to reach you at. Google, + GitHub, or a code sent to any email — no account needed beforehand, + and it works wherever you are in the world. +

+ + Sign in to join the list + +
+ ); + } + + return ( +
+
+

+ {onList ? "You're on the list" : "Join the interest list"} +

+

+ {onList + ? `We'll email ${session.user?.email} the moment registration opens.` + : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."} +

+
+ + {showForm ? ( +
{ + e.preventDefault(); + const parsedYear = graduationYear.trim() + ? Number(graduationYear) + : null; + if ( + parsedYear !== null && + (!Number.isInteger(parsedYear) || + parsedYear < 1900 || + parsedYear > 2100) + ) { + setError("That graduation year does not look right."); + return; + } + join.mutate({ + hackathonId, + school: school.trim() || undefined, + country: country.trim() || undefined, + graduationYear: parsedYear, + experience: experience || undefined, + }); + }} + > + + setSchool(e.target.value)} + placeholder="Georgia Institute of Technology" + maxLength={200} + /> + + + + setCountry(e.target.value)} + placeholder="United States" + maxLength={100} + /> + + + + setGraduationYear(e.target.value)} + placeholder="2029" + inputMode="numeric" + /> + + + + + + + {error ? ( +

{error}

+ ) : null} + +
+ + {onList ? ( + + ) : null} +
+
+ ) : ( +
+ + +
+ )} + + {error && !showForm ? ( +

{error}

+ ) : null} +
+ ); +} diff --git a/sites/mainweb/components/portal/PortalSidebar.tsx b/sites/mainweb/components/portal/PortalSidebar.tsx index d9355497..ee1ddd78 100644 --- a/sites/mainweb/components/portal/PortalSidebar.tsx +++ b/sites/mainweb/components/portal/PortalSidebar.tsx @@ -6,32 +6,17 @@ import Image from "next/image"; import { usePathname } from "next/navigation"; import { useSession, signOut } from "next-auth/react"; import { - LayoutDashboard, - Code, - ClipboardList, - Users, - BarChart3, LogOut, Menu, - QrCode, - Zap, X, Sun, Moon, Home, - ShieldAlert, - UserCircle, - Rocket, - Upload, - FolderGit2, - CreditCard, - ShieldCheck, - ScrollText, - BookOpen, - GraduationCap, + Zap, } from "lucide-react"; import { useTheme } from "next-themes"; import { usePortalContext } from "@/lib/use-portal-context"; +import { isPortalNavActive, portalNavSections } from "@/lib/portal-nav"; import logo from "../../assets/images/dsgt/apple-touch-icon.png"; interface PortalSidebarProps { @@ -50,6 +35,7 @@ export default function PortalSidebar({ const [mounted, setMounted] = useState(false); const { data: portalContext } = usePortalContext(); + const sections = portalNavSections(portalContext); useEffect(() => { setMounted(true); @@ -66,106 +52,6 @@ export default function PortalSidebar({ if (pathname === "/login" || pathname === "/verify") return null; - const mainRoutes = [ - { - name: "Dashboard", - href: "/dashboard", - icon: Home, - show: !portalContext?.isAdmin, - }, - { - name: "Hackathons", - href: "/hackathons", - icon: Zap, - show: !portalContext?.isAdmin, - }, - { - name: "Check-In Desk", - href: "/scan", - icon: QrCode, - // Volunteers hold an admins row but isAdmin rejects them, so the admin - // nav below is invisible to them — this is their only entry point. - show: portalContext?.isScanner && !portalContext?.isAdmin, - }, - { - name: "Submit Project", - href: "/submit", - icon: Upload, - // The only route to team.submitProject, and nothing else in the product - // linked to it — so no project could be submitted, and with submissions - // now feeding judging, nothing could be judged either. - show: !portalContext?.isAdmin, - }, - { - name: "Club Portal", - href: "/club", - icon: QrCode, - show: portalContext?.member.isMember && !portalContext?.isAdmin, - }, - { - name: "Bootcamp", - // Not /bootcamp — the public curriculum page owns that path. Shown to - // everyone, like Initiatives, so the add-on can be seen before paying. - href: "/club/bootcamp", - icon: GraduationCap, - show: !portalContext?.isAdmin, - }, - { - name: "Judge Portal", - href: "/judge", - icon: ClipboardList, - // Admins get this in the admin nav instead, so their sidebar stays - // admin-only. - show: portalContext?.isJudge && !portalContext?.isAdmin, - }, - { - name: "Initiatives", - href: "/initiatives", - icon: Rocket, - // Somebody deciding whether to pay should be able to see what membership - // gets them. Applying is where the membership check bites, not browsing. - show: !portalContext?.isAdmin, - }, - { - name: "My Initiatives", - href: "/lead", - icon: Rocket, - show: portalContext?.isProjectLeader && !portalContext?.isAdmin, - }, - { - name: "Settings", - href: "/settings", - icon: UserCircle, - show: true, - }, - ].filter((r) => r.show); - - const adminRoutes = [ - { name: "Club Hub", href: "/admin", icon: LayoutDashboard }, - { name: "Hackathons", href: "/admin/hackathons", icon: Code }, - { name: "Judging", href: "/admin/judging", icon: ClipboardList }, - // Judge import and queue assignment live only here. Without this entry the - // page is reachable by typed URL alone, which means judging never starts. - { name: "Judging Setup", href: "/admin/setup", icon: Upload }, - { name: "Projects", href: "/admin/projects", icon: FolderGit2 }, - { name: "Initiatives", href: "/admin/initiatives", icon: Rocket }, - // /lead is the only screen that decides an initiative application, and - // isProjectLeader admits admins so an absent leader cannot strand theirs. - { name: "Initiative Applications", href: "/lead", icon: Rocket }, - { name: "Bootcamp", href: "/admin/bootcamp", icon: GraduationCap }, - { name: "Attendees", href: "/admin/attendees", icon: Users }, - { name: "Memberships", href: "/admin/members", icon: CreditCard }, - // Granting the volunteer tier is otherwise an INSERT against production, - // and /scan's rejection screen tells people to ask an organiser for it. - { name: "Staff & Roles", href: "/admin/staff", icon: ShieldCheck }, - { name: "Analytics", href: "/admin/analytics", icon: BarChart3 }, - // Retention prunes routine entries at 90 days, so an unreadable log is an - // expiring one. - { name: "Audit Log", href: "/admin/audit", icon: ScrollText }, - // Shipped in every build and previously reachable only by typing the URL. - { name: "Docs", href: "/docs", icon: BookOpen }, - ]; - return ( <> {/* Mobile Top Bar */} @@ -205,52 +91,20 @@ export default function PortalSidebar({
- {mainRoutes.length > 0 && ( -
-

- - Main Navigation -

-
- {mainRoutes.map((route) => { - const isActive = - pathname === route.href || - (route.href !== "/dashboard" && - pathname.startsWith(route.href + "/")); - return ( - setIsMobileOpen(false)} - className={`flex items-center justify-center gap-3 py-4 rounded-none transition-ui ${ - isActive - ? "bg-accent/10 text-accent border border-accent/20 font-bold" - : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]" - }`} - > - - {route.name} - - ); - })} -
-
- )} - - {portalContext?.isAdmin && ( - <> -
-
-

- - Admin Area + {sections.map((section, index) => { + const SectionIcon = section.id === "hackathon" ? Zap : Home; + return ( +
+ {index > 0 && ( +
+ )} +

+ + {section.label}

- {adminRoutes.map((route) => { - const isActive = - pathname === route.href || - (route.href !== "/admin" && - pathname.startsWith(route.href + "/")); + {section.items.map((route) => { + const isActive = isPortalNavActive(pathname, route.href); return (
- - )} + ); + })} {/* Mobile User Section */}
@@ -371,74 +225,29 @@ export default function PortalSidebar({ {/* Navigation */} {/* User section */} diff --git a/sites/mainweb/components/portal/ScanAccess.tsx b/sites/mainweb/components/portal/ScanAccess.tsx new file mode 100644 index 00000000..d0ccc819 --- /dev/null +++ b/sites/mainweb/components/portal/ScanAccess.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { usePortalContext } from "@/lib/use-portal-context"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; + +/** + * Volunteers are not admins, so both check-in desks live outside /admin. + * Club scanning and hackathon scanning are separate pages — mixing them on + * one screen put club meetings inside the hackathon desk. + */ +export function ScanAccess({ children }: { children: React.ReactNode }) { + const { status } = useSession(); + const router = useRouter(); + const { data: portalContext, isLoading } = usePortalContext(); + + if (status === "loading" || isLoading) { + return ; + } + + if (status === "unauthenticated") { + router.push("/login"); + return null; + } + + if (!portalContext?.isScanner) { + return ( +
+ +

+ Not a Scanner +

+

+ This is the event check-in desk. Ask an organiser to add you as + event staff. +

+ + Back to Dashboard + +
+
+ ); + } + + return <>{children}; +} diff --git a/sites/mainweb/lib/hackathon-slug.test.ts b/sites/mainweb/lib/hackathon-slug.test.ts index a099326b..e001e19d 100644 --- a/sites/mainweb/lib/hackathon-slug.test.ts +++ b/sites/mainweb/lib/hackathon-slug.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { hackathonSlug } from "./hackathon-slug"; +import { + hackathonSlug, + decodeHackathonParam, + hackathonPathSegment, + adminHackathonPath, +} from "./hackathon-slug"; describe("hackathonSlug", () => { it("turns an edition name into a URL segment", () => { @@ -30,3 +35,54 @@ describe("hackathonSlug", () => { expect(hackathonSlug("")).toBe(""); }); }); + +describe("decodeHackathonParam", () => { + it("undoes the percent-encoding admin dashboard links used to put in the path", () => { + expect( + decodeHackathonParam(encodeURIComponent("Hacklytics: Digital Bloom")), + ).toBe("Hacklytics: Digital Bloom"); + }); + + it("undoes a double-encoded name from Next.js Link + encodeURIComponent", () => { + const name = "Hacklytics: Digital Bloom"; + expect(decodeHackathonParam(encodeURIComponent(encodeURIComponent(name)))).toBe( + name, + ); + }); + + it("leaves a slug and a raw name alone", () => { + expect(decodeHackathonParam("hacklytics-digital-bloom")).toBe( + "hacklytics-digital-bloom", + ); + expect(decodeHackathonParam("Hacklytics: Digital Bloom")).toBe( + "Hacklytics: Digital Bloom", + ); + }); + + it("returns the original string when it is not valid percent-encoding", () => { + expect(decodeHackathonParam("%")).toBe("%"); + }); +}); + +describe("hackathonPathSegment", () => { + it("uses the slug for a named edition", () => { + expect( + hackathonPathSegment("Hacklytics: Digital Bloom", "uuid-bloom"), + ).toBe("hacklytics-digital-bloom"); + }); + + it("falls back to the id when the name has nothing sluggable", () => { + expect(hackathonPathSegment("!!!", "uuid-bloom")).toBe("uuid-bloom"); + }); +}); + +describe("adminHackathonPath", () => { + it("builds a slug URL, not an encoded name", () => { + expect(adminHackathonPath("Hacklytics: Digital Bloom", "uuid-bloom")).toBe( + "/admin/hackathons/hacklytics-digital-bloom", + ); + expect(adminHackathonPath("Hacklytics: Digital Bloom", "uuid-bloom")).not.toContain( + "%", + ); + }); +}); diff --git a/sites/mainweb/lib/hackathon-slug.ts b/sites/mainweb/lib/hackathon-slug.ts index 18a8f975..89a91576 100644 --- a/sites/mainweb/lib/hackathon-slug.ts +++ b/sites/mainweb/lib/hackathon-slug.ts @@ -9,3 +9,36 @@ export function hackathonSlug(name: string): string { .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } + +/** + * Admin dashboard used to put `encodeURIComponent(name)` in the path. A name + * like "Hacklytics: Digital Bloom" became `Hacklytics%3A%20Digital%20Bloom`, + * which matches neither the stored name nor the slug — and that is the + * "Hackathon not found" screen. Undo one or two encode passes so old links + * still resolve. + */ +export function decodeHackathonParam(value: string): string { + let current = value; + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(current); + if (next === current) break; + current = next; + } catch { + break; + } + } + return current; +} + +/** + * Path segment for a hackathon URL. Prefer the slug; fall back to the id when + * the name has nothing sluggable (`!!!`), so the link still opens something. + */ +export function hackathonPathSegment(name: string, id: string): string { + return hackathonSlug(name) || id; +} + +export function adminHackathonPath(name: string, id: string): string { + return `/admin/hackathons/${hackathonPathSegment(name, id)}`; +} diff --git a/sites/mainweb/lib/judging-prep.test.ts b/sites/mainweb/lib/judging-prep.test.ts new file mode 100644 index 00000000..0b05133e --- /dev/null +++ b/sites/mainweb/lib/judging-prep.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { judgingPrepIsCurrent } from "./judging-prep"; + +const A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + +describe("judgingPrepIsCurrent", () => { + it("accepts a result for the edition and run that started it", () => { + expect(judgingPrepIsCurrent(A, 3, A, 3)).toBe(true); + }); + + it("drops a result after the organiser switches editions", () => { + expect(judgingPrepIsCurrent(A, 3, B, 4)).toBe(false); + expect(judgingPrepIsCurrent(A, 3, B, 3)).toBe(false); + }); + + it("drops a result after a newer prepare or rebuild on the same edition", () => { + expect(judgingPrepIsCurrent(A, 3, A, 4)).toBe(false); + }); +}); diff --git a/sites/mainweb/lib/judging-prep.ts b/sites/mainweb/lib/judging-prep.ts new file mode 100644 index 00000000..5dc63187 --- /dev/null +++ b/sites/mainweb/lib/judging-prep.ts @@ -0,0 +1,16 @@ +/** + * Prepare/rebuild results must not apply after the organiser has switched + * editions or started a newer run. A CONFLICT from A that lands after the + * selector moved to B would paint A's refusal on B, and Rebuild anyway would + * send force: true for B. + */ +export function judgingPrepIsCurrent( + startedHackathonId: string, + startedGen: number, + selectedHackathonId: string | null, + currentGen: number, +): boolean { + return ( + startedGen === currentGen && startedHackathonId === selectedHackathonId + ); +} diff --git a/sites/mainweb/lib/portal-nav.test.ts b/sites/mainweb/lib/portal-nav.test.ts new file mode 100644 index 00000000..edef059b --- /dev/null +++ b/sites/mainweb/lib/portal-nav.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import type { PortalContext } from "@query/api"; +import { + isPortalNavActive, + portalNavSections, +} from "./portal-nav"; + +const emptyMember = { + isMember: false, + isActive: false, + hasLapsed: false, + expiresAt: null, + daysRemaining: null, + memberType: null, + renewalCount: 0, +} satisfies PortalContext["member"]; + +function ctx(overrides: Partial = {}): PortalContext { + return { + isAdmin: false, + isScanner: false, + role: null, + permissions: [], + isJudge: false, + judgeId: null, + judgeName: null, + isProjectLeader: false, + member: emptyMember, + ...overrides, + }; +} + +function names( + sections: ReturnType, + id: "hackathon" | "portal", +): string[] { + return sections.find((s) => s.id === id)?.items.map((i) => i.name) ?? []; +} + +function hrefs(sections: ReturnType): string[] { + return sections.flatMap((s) => s.items.map((i) => i.href)); +} + +describe("portalNavSections", () => { + it("splits hackathon and portal for a guest, with Dashboard on the hackathon side", () => { + const sections = portalNavSections(ctx()); + expect(sections.map((s) => s.id)).toEqual(["hackathon", "portal"]); + expect(names(sections, "hackathon")).toEqual([ + "Dashboard", + "Hackathons", + "Submit Project", + ]); + expect(names(sections, "portal")).toEqual([ + "Bootcamp", + "Initiatives", + "Settings", + ]); + expect(names(sections, "portal")).not.toContain("Club Portal"); + }); + + it("moves Dashboard and Club Portal onto the portal side for a member", () => { + const sections = portalNavSections( + ctx({ member: { ...emptyMember, isMember: true, isActive: true } }), + ); + expect(names(sections, "hackathon")).toEqual([ + "Hackathons", + "Submit Project", + ]); + expect(names(sections, "hackathon")).not.toContain("Dashboard"); + expect(names(sections, "portal")).toEqual([ + "Dashboard", + "Club Portal", + "Bootcamp", + "Initiatives", + "Settings", + ]); + }); + + it("adds judge and scanner links only on the hackathon side", () => { + const sections = portalNavSections( + ctx({ isJudge: true, isScanner: true }), + ); + expect(names(sections, "hackathon")).toEqual([ + "Dashboard", + "Hackathons", + "Submit Project", + "Judge Portal", + "Check-In Desk", + ]); + expect(names(sections, "portal")).toContain("Club Check-In"); + expect(names(sections, "hackathon")).not.toContain("Club Check-In"); + expect(names(sections, "portal")).not.toContain("Judge Portal"); + }); + + it("adds My Initiatives on the portal side for a project leader", () => { + const sections = portalNavSections(ctx({ isProjectLeader: true })); + expect(names(sections, "portal")).toContain("My Initiatives"); + expect(names(sections, "hackathon")).not.toContain("My Initiatives"); + }); + + it("splits admin tools the same way and omits Judging Setup", () => { + const sections = portalNavSections(ctx({ isAdmin: true, role: "admin" })); + expect(names(sections, "hackathon")).toEqual([ + "Hackathons", + "Judging", + "Projects", + ]); + expect(names(sections, "hackathon")).not.toContain("Attendees"); + expect(names(sections, "hackathon")).not.toContain("Club Attendees"); + expect(names(sections, "portal")).toEqual([ + "Club Hub", + "Club Attendees", + "Club Check-In", + "Initiatives", + "Initiative Applications", + "Bootcamp", + "Memberships", + "Staff & Roles", + "Analytics", + "Audit Log", + "Docs", + "Settings", + ]); + expect(hrefs(sections)).not.toContain("/admin/setup"); + expect(sections.flatMap((s) => s.items.map((i) => i.name))).not.toContain( + "Judging Setup", + ); + }); +}); + +describe("isPortalNavActive", () => { + it("does not treat /admin as a prefix of every admin page", () => { + expect(isPortalNavActive("/admin", "/admin")).toBe(true); + expect(isPortalNavActive("/admin/hackathons", "/admin")).toBe(false); + expect( + isPortalNavActive("/admin/hackathons/bloom", "/admin/hackathons"), + ).toBe(true); + }); + + it("does not treat /dashboard as a prefix of other routes", () => { + expect(isPortalNavActive("/dashboard", "/dashboard")).toBe(true); + expect(isPortalNavActive("/hackathons", "/dashboard")).toBe(false); + }); + + it("does not treat /club as a prefix of /club/bootcamp", () => { + expect(isPortalNavActive("/club", "/club")).toBe(true); + expect(isPortalNavActive("/club/bootcamp", "/club")).toBe(false); + expect(isPortalNavActive("/club/bootcamp", "/club/bootcamp")).toBe(true); + }); + + it("does not treat /scan as a prefix of /scan/club", () => { + expect(isPortalNavActive("/scan", "/scan")).toBe(true); + expect(isPortalNavActive("/scan/club", "/scan")).toBe(false); + expect(isPortalNavActive("/scan/club", "/scan/club")).toBe(true); + }); +}); diff --git a/sites/mainweb/lib/portal-nav.ts b/sites/mainweb/lib/portal-nav.ts new file mode 100644 index 00000000..5037a0a7 --- /dev/null +++ b/sites/mainweb/lib/portal-nav.ts @@ -0,0 +1,163 @@ +import type { LucideIcon } from "lucide-react"; +import { + LayoutDashboard, + Code, + ClipboardList, + Users, + BarChart3, + QrCode, + Zap, + Home, + Rocket, + Upload, + FolderGit2, + CreditCard, + ShieldCheck, + ScrollText, + BookOpen, + GraduationCap, + UserCircle, + Calendar, +} from "lucide-react"; +import type { PortalContext } from "@query/api"; + +export type PortalNavItem = { + name: string; + href: string; + icon: LucideIcon; +}; + +export type PortalNavSection = { + id: "hackathon" | "portal"; + label: string; + items: PortalNavItem[]; +}; + +type NavFlags = { + isAdmin: boolean; + isScanner: boolean; + isJudge: boolean; + isMember: boolean; + isProjectLeader: boolean; +}; + +function flags(ctx: PortalContext | undefined | null): NavFlags { + return { + isAdmin: !!ctx?.isAdmin, + isScanner: !!ctx?.isScanner, + isJudge: !!ctx?.isJudge, + isMember: !!ctx?.member.isMember, + isProjectLeader: !!ctx?.isProjectLeader, + }; +} + +/** + * Sidebar is two columns of the org, not one dump: hackathon (open to anyone + * with an account) and portal (club). Membership decides which portal links + * appear; it does not mix the two back together. + * + * Staff see the same split on the admin tools. Judging Setup is gone — an + * edition created from /admin/hackathons is the judging edition, and queue + * prep lives on /admin/judging. + * + * Club meetings (Hub, attendees, pass scan) stay on Portal. They are not a + * tab or mode of a hackathon edition. + */ +export function portalNavSections( + ctx: PortalContext | undefined | null, +): PortalNavSection[] { + const f = flags(ctx); + + if (f.isAdmin) { + return [ + { + id: "hackathon", + label: "Hackathon", + items: [ + { name: "Hackathons", href: "/admin/hackathons", icon: Code }, + { name: "Judging", href: "/admin/judging", icon: ClipboardList }, + { name: "Projects", href: "/admin/projects", icon: FolderGit2 }, + ], + }, + { + id: "portal", + label: "Portal", + items: [ + { name: "Club Hub", href: "/admin", icon: LayoutDashboard }, + { name: "Club Attendees", href: "/admin/attendees", icon: Users }, + { name: "Club Check-In", href: "/scan/club", icon: Calendar }, + { name: "Initiatives", href: "/admin/initiatives", icon: Rocket }, + { + name: "Initiative Applications", + href: "/lead", + icon: Rocket, + }, + { name: "Bootcamp", href: "/admin/bootcamp", icon: GraduationCap }, + { name: "Memberships", href: "/admin/members", icon: CreditCard }, + { name: "Staff & Roles", href: "/admin/staff", icon: ShieldCheck }, + { name: "Analytics", href: "/admin/analytics", icon: BarChart3 }, + { name: "Audit Log", href: "/admin/audit", icon: ScrollText }, + { name: "Docs", href: "/docs", icon: BookOpen }, + { name: "Settings", href: "/settings", icon: UserCircle }, + ], + }, + ]; + } + + const hackathon: PortalNavItem[] = [ + // Non-members land on the hackathon half of /dashboard; members get the + // same home link under Portal so the default section matches the default view. + ...(!f.isMember + ? [{ name: "Dashboard", href: "/dashboard", icon: Home }] + : []), + { name: "Hackathons", href: "/hackathons", icon: Zap }, + { + name: "Submit Project", + href: "/submit", + icon: Upload, + }, + ...(f.isJudge + ? [{ name: "Judge Portal", href: "/judge", icon: ClipboardList }] + : []), + ...(f.isScanner + ? [{ name: "Check-In Desk", href: "/scan", icon: QrCode }] + : []), + ]; + + const portal: PortalNavItem[] = [ + ...(f.isMember + ? [ + { name: "Dashboard", href: "/dashboard", icon: Home }, + { name: "Club Portal", href: "/club", icon: QrCode }, + ] + : []), + ...(f.isScanner + ? [{ name: "Club Check-In", href: "/scan/club", icon: Calendar }] + : []), + { name: "Bootcamp", href: "/club/bootcamp", icon: GraduationCap }, + { name: "Initiatives", href: "/initiatives", icon: Rocket }, + ...(f.isProjectLeader + ? [{ name: "My Initiatives", href: "/lead", icon: Rocket }] + : []), + { name: "Settings", href: "/settings", icon: UserCircle }, + ]; + + return [ + { id: "hackathon", label: "Hackathon", items: hackathon }, + { id: "portal", label: "Portal", items: portal }, + ]; +} + +export function isPortalNavActive(pathname: string, href: string): boolean { + if (pathname === href) return true; + // Prefixes that own a child route with its own nav item. + if ( + href === "/dashboard" || + href === "/admin" || + href === "/club" || + href === "/scan" + ) { + return false; + } + return pathname.startsWith(`${href}/`); +} diff --git a/sites/mainweb/proxy.ts b/sites/mainweb/proxy.ts index dec3ec62..7e509435 100644 --- a/sites/mainweb/proxy.ts +++ b/sites/mainweb/proxy.ts @@ -37,6 +37,7 @@ const PRIVATE_PREFIXES = [ "/submit", "/verify", "/login", + "/scan", ]; function getCacheControl(pathname: string): string {