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/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/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index 4d7bdc77..a0ddfa8c 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(); 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)/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..b8f2d6a0 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( diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx index 8c74e81d..32e784a6 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({ @@ -54,7 +55,7 @@ export function HackathonCard({

{hackathon.name} @@ -134,7 +135,7 @@ export function HackathonCard({

{events && events.length > 0 && ( Manage Events ({events.length}) → @@ -263,7 +264,7 @@ export function HackathonCard({ {events.length > 4 && (
+ {events.length - 4} more scheduled events (Click to view @@ -343,7 +344,7 @@ export function HackathonCard({ Dashboard 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/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/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/portal-nav.test.ts b/sites/mainweb/lib/portal-nav.test.ts new file mode 100644 index 00000000..306b115c --- /dev/null +++ b/sites/mainweb/lib/portal-nav.test.ts @@ -0,0 +1,145 @@ +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")).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", + "Attendees", + ]); + expect(names(sections, "portal")).toEqual([ + "Club Hub", + "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); + }); +}); diff --git a/sites/mainweb/lib/portal-nav.ts b/sites/mainweb/lib/portal-nav.ts new file mode 100644 index 00000000..953aee7f --- /dev/null +++ b/sites/mainweb/lib/portal-nav.ts @@ -0,0 +1,150 @@ +import type { LucideIcon } from "lucide-react"; +import { + LayoutDashboard, + Code, + ClipboardList, + Users, + BarChart3, + QrCode, + Zap, + Home, + Rocket, + Upload, + FolderGit2, + CreditCard, + ShieldCheck, + ScrollText, + BookOpen, + GraduationCap, + UserCircle, +} 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. + */ +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 }, + { name: "Attendees", href: "/admin/attendees", icon: Users }, + ], + }, + { + id: "portal", + label: "Portal", + items: [ + { name: "Club Hub", href: "/admin", icon: LayoutDashboard }, + { 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 }, + ] + : []), + { 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") { + return false; + } + return pathname.startsWith(`${href}/`); +}