diff --git a/packages/api/src/routers/hackathon/admin/attendee-where.ts b/packages/api/src/routers/hackathon/admin/attendee-where.ts index 39d13370..e275a94c 100644 --- a/packages/api/src/routers/hackathon/admin/attendee-where.ts +++ b/packages/api/src/routers/hackathon/admin/attendee-where.ts @@ -41,6 +41,11 @@ export const buildAttendeeWhere = (input: { // Escaped so a literal % or _ in a name searches for that character instead // of turning into a wildcard. const pattern = `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`; + // The user table gets its own alias, written by hand. Inside the relational + // roster query (db.query...findMany) Drizzle renders every column in this + // template against the participants alias, so `${users.id}` came out as + // "hackathonParticipants"."id" (a uuid) compared with user_id (text) and + // every search answered 500. clauses.push( sql`( ${hackathonParticipants.firstName} ilike ${pattern} @@ -49,9 +54,9 @@ export const buildAttendeeWhere = (input: { or ${hackathonParticipants.major} ilike ${pattern} or ${hackathonParticipants.whyAttend} ilike ${pattern} or exists ( - select 1 from ${users} - where ${users.id} = ${hackathonParticipants.userId} - and (${users.name} ilike ${pattern} or ${users.email} ilike ${pattern}) + select 1 from ${users} as attendee_user + where attendee_user."id" = ${hackathonParticipants.userId} + and (attendee_user."name" ilike ${pattern} or attendee_user."email" ilike ${pattern}) ) )`, ); diff --git a/sites/mainweb/app/(portal)/admin/AdminPage.tsx b/sites/mainweb/app/(portal)/admin/AdminPage.tsx new file mode 100644 index 00000000..ab294ff0 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/AdminPage.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { venueFormat } from "@/lib/venue-time"; +import { useSession } from "next-auth/react"; +import { trpc } from "@/lib/trpc"; +import { + canViewAdmin, + READ_ONLY_TITLE, + usePortalContext, + useReadOnly, +} from "@/lib/use-portal-context"; +import { useState } from "react"; +import Link from "next/link"; +import { useEventQR } from "@/components/portal/EventQR"; +import { EventFormModal } from "@/components/portal/EventFormModal"; +import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; +import { + body, + btnDanger, + btnPrimary, + btnSecondary, + chip, + itemTitle, + meta, + object, + page, + pageDek, + sectionRule, + status, + textLink, +} from "@/components/portal/ui"; + +const adminTitle = + "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)] text-balance"; + +const inlineLink = + "font-semibold text-[var(--text-primary)] underline decoration-accent underline-offset-4 hover:decoration-[var(--text-primary)]"; + +type Event = { + id: string; + title: string; + description: string | null; + location: string | null; + eventDate: Date; + qrCode: string; + checkInEnabled: boolean; + currentCheckIns: number; + maxCheckIns: number | null; +}; + +export default function AdminPage() { + const { data: session } = useSession(); + const utils = trpc.useUtils(); + + const [showCreateEvent, setShowCreateEvent] = useState(false); + const qr = useEventQR(); + const [editingEvent, setEditingEvent] = useState(null); + const [attendanceEvent, setAttendanceEvent] = useState(null); + const [editError, setEditError] = useState(null); + + const { data: portalContext } = usePortalContext(); + const readOnly = useReadOnly(); + const { data: allEvents, isLoading: eventsLoading } = + trpc.events.listAll.useQuery(undefined, { + enabled: !!session && canViewAdmin(portalContext), + }); + // Bootcamp sessions have their own check-in controls on /admin/bootcamp. + const events = allEvents?.filter((e) => e.kind === "club"); + + type StatusFilter = "all" | "open" | "closed"; + const [statusFilter, setStatusFilter] = useState("all"); + + const createEventMutation = trpc.events.create.useMutation({ + onSuccess: (newEvent) => { + if (newEvent) { + utils.events.listAll.invalidate(); + setShowCreateEvent(false); + void qr.show(newEvent); + } + }, + }); + + const toggleCheckInMutation = trpc.events.toggleCheckIn.useMutation({ + onSuccess: () => utils.events.listAll.invalidate(), + }); + + const deleteEventMutation = trpc.events.delete.useMutation({ + onSuccess: () => { + utils.events.listAll.invalidate(); + qr.hide(); + }, + }); + + // Editing rather than delete-and-recreate: deleting takes every check-in + // already collected with it, and mints a QR nobody's printed sign matches. + const updateEventMutation = trpc.events.update.useMutation({ + onSuccess: () => { + utils.events.listAll.invalidate(); + setEditingEvent(null); + setEditError(null); + }, + onError: (error) => setEditError(error.message), + }); + + type EventForm = { + title: string; + description: string; + location: string; + eventDate: string; + maxCheckIns: string; + }; + + // Empty means no cap — the column is nullable and the door gate only runs + // when a number is set. The form used to hardcode undefined, so capacity was + // unreachable from anywhere in the product. + const parseCapacity = (value: string) => { + const parsed = Number(value); + return value.trim() && Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : undefined; + }; + + const handleCreateEvent = (formData: EventForm) => { + createEventMutation.mutate({ + title: formData.title, + description: formData.description || undefined, + location: formData.location || undefined, + eventDate: new Date(formData.eventDate), + maxCheckIns: parseCapacity(formData.maxCheckIns), + }); + }; + + const handleEditEvent = (formData: EventForm) => { + if (!editingEvent) return; + setEditError(null); + updateEventMutation.mutate({ + eventId: editingEvent.id, + title: formData.title, + description: formData.description || null, + location: formData.location || null, + eventDate: new Date(formData.eventDate), + maxCheckIns: parseCapacity(formData.maxCheckIns) ?? null, + }); + }; + + /** `datetime-local` wants local wall-clock, not the UTC ISO string. */ + const toLocalInput = (date: Date) => { + const local = new Date( + date.getTime() - new Date(date).getTimezoneOffset() * 60 * 1000, + ); + return local.toISOString().slice(0, 16); + }; + + return ( + <> + {showCreateEvent && ( + setShowCreateEvent(false)} + onSubmit={handleCreateEvent} + isSubmitting={createEventMutation.isPending} + /> + )} + + {editingEvent && ( + { + setEditingEvent(null); + setEditError(null); + }} + onSubmit={handleEditEvent} + isSubmitting={updateEventMutation.isPending} + error={editError} + /> + )} + + {attendanceEvent && ( + setAttendanceEvent(null)} + /> + )} + + {qr.modal} + +
+
+

Club check-ins

+

+ Club meetings and workshops. Bootcamp sessions are managed on the{" "} + + bootcamp page + + , and Hacklytics weekend events live on the hackathon dashboard. +

+
+ +
+
+
+ {(["all", "open", "closed"] as const).map((f) => ( + + ))} +
+
+ + Scan passes + + +
+
+ + {eventsLoading ? ( +
+
+ + ); +} diff --git a/sites/mainweb/app/(portal)/admin/attendees/AttendeesPage.tsx b/sites/mainweb/app/(portal)/admin/attendees/AttendeesPage.tsx new file mode 100644 index 00000000..ad5ae4ec --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/attendees/AttendeesPage.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { loginHref } from "@/lib/safe-callback"; +import { trpc } from "@/lib/trpc"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { skipToken } from "@tanstack/react-query"; +import { Download } from "lucide-react"; +import { + body, + btnSecondary, + fieldLabel, + input, + meta, + page, + pageDek, + sectionRule, + status as statusText, +} from "@/components/portal/ui"; +import { EventOptions } from "@/components/portal/EventOptions"; + +const adminTitle = + "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)] text-balance"; + +const th = "px-4 py-3 text-[13px] font-medium text-[var(--text-subtle)]"; +const td = "px-4 py-3"; + +export default function AttendeesPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + + const [selectedEvent, setSelectedEvent] = useState(null); + + const { data: eventList } = trpc.events.listAll.useQuery(undefined, { + enabled: !!session, + }); + + const { data: eventData, isLoading } = trpc.events.getById.useQuery( + selectedEvent ? { id: selectedEvent } : skipToken, + ); + + const attendees = eventData?.checkIns; + + if (status === "unauthenticated") { + router.push(loginHref()); + return null; + } + + const handleDownloadCSV = () => { + if (!attendees || attendees.length === 0) return; + + const cell = (value: unknown) => + `"${String(value ?? "").replace(/"/g, '""')}"`; + + const rows = attendees.map((a: NonNullable[number]) => + [ + a.member ? `${a.member.firstName} ${a.member.lastName}` : a.user?.name, + a.user?.email, + a.checkInMethod, + a.checkedInAt ? new Date(a.checkedInAt).toISOString() : "", + ].map(cell), + ); + + const csv = [ + // No "Points" column: every row carried the same schema default, so the + // export presented a constant as though it were tracked data. + ["Name", "Email", "Method", "Checked In At"].map(cell), + ...rows, + ] + .map((r) => r.join(",")) + .join("\n"); + + const url = URL.createObjectURL( + new Blob([csv], { type: "text/csv;charset=utf-8;" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = `${(eventData?.title ?? "event") + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase()}_attendees.csv`; + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // Without this the blob is retained for the lifetime of the page. + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Club event attendees

+

+ Who checked in to each club meeting. Hackathon applications live on + each edition's dashboard. +

+
+ +
+
+
+ + +
+ {selectedEvent && ( + + )} +
+ + {isLoading ? ( +

Loading check-ins…

+ ) : !attendees || attendees.length === 0 ? ( +

+ {selectedEvent + ? "Nobody has checked in to this event yet. Check-ins appear here as people arrive." + : "Pick a club event to see who checked in."} +

+ ) : ( +
+ + + + + + + + + + + {attendees.map((attendee) => ( + + + + + + + ))} + +
+ Name + + Email + + Status + + Checked in at +
+
+ {} + {attendee.user?.name + + {attendee.user?.name || + `${attendee.member?.firstName ?? ""} ${attendee.member?.lastName ?? ""}`.trim() || + "Unknown"} + +
+
+ {attendee.user?.email} + + Checked in + + {attendee.checkedInAt + ? new Date(attendee.checkedInAt).toLocaleString() + : "—"} +
+
+ )} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index ad5ae4ec..787031ae 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -1,203 +1,14 @@ -"use client"; - -import { useSession } from "next-auth/react"; -import { loginHref } from "@/lib/safe-callback"; -import { trpc } from "@/lib/trpc"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { skipToken } from "@tanstack/react-query"; -import { Download } from "lucide-react"; -import { - body, - btnSecondary, - fieldLabel, - input, - meta, - page, - pageDek, - sectionRule, - status as statusText, -} from "@/components/portal/ui"; -import { EventOptions } from "@/components/portal/EventOptions"; - -const adminTitle = - "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)] text-balance"; - -const th = "px-4 py-3 text-[13px] font-medium text-[var(--text-subtle)]"; -const td = "px-4 py-3"; - -export default function AttendeesPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - - const [selectedEvent, setSelectedEvent] = useState(null); - - const { data: eventList } = trpc.events.listAll.useQuery(undefined, { - enabled: !!session, - }); - - const { data: eventData, isLoading } = trpc.events.getById.useQuery( - selectedEvent ? { id: selectedEvent } : skipToken, - ); - - const attendees = eventData?.checkIns; - - if (status === "unauthenticated") { - router.push(loginHref()); - return null; - } - - const handleDownloadCSV = () => { - if (!attendees || attendees.length === 0) return; - - const cell = (value: unknown) => - `"${String(value ?? "").replace(/"/g, '""')}"`; - - const rows = attendees.map((a: NonNullable[number]) => - [ - a.member ? `${a.member.firstName} ${a.member.lastName}` : a.user?.name, - a.user?.email, - a.checkInMethod, - a.checkedInAt ? new Date(a.checkedInAt).toISOString() : "", - ].map(cell), - ); - - const csv = [ - // No "Points" column: every row carried the same schema default, so the - // export presented a constant as though it were tracked data. - ["Name", "Email", "Method", "Checked In At"].map(cell), - ...rows, - ] - .map((r) => r.join(",")) - .join("\n"); - - const url = URL.createObjectURL( - new Blob([csv], { type: "text/csv;charset=utf-8;" }), - ); - const link = document.createElement("a"); - link.href = url; - link.download = `${(eventData?.title ?? "event") - .replace(/[^a-z0-9]/gi, "_") - .toLowerCase()}_attendees.csv`; - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - // Without this the blob is retained for the lifetime of the page. - URL.revokeObjectURL(url); - }; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AttendeesPage from "./AttendeesPage"; +export default function Page() { return ( -
-
-

Club event attendees

-

- Who checked in to each club meeting. Hackathon applications live on - each edition's dashboard. -

-
- -
-
-
- - -
- {selectedEvent && ( - - )} -
- - {isLoading ? ( -

Loading check-ins…

- ) : !attendees || attendees.length === 0 ? ( -

- {selectedEvent - ? "Nobody has checked in to this event yet. Check-ins appear here as people arrive." - : "Pick a club event to see who checked in."} -

- ) : ( -
- - - - - - - - - - - {attendees.map((attendee) => ( - - - - - - - ))} - -
- Name - - Email - - Status - - Checked in at -
-
- {} - {attendee.user?.name - - {attendee.user?.name || - `${attendee.member?.firstName ?? ""} ${attendee.member?.lastName ?? ""}`.trim() || - "Unknown"} - -
-
- {attendee.user?.email} - - Checked in - - {attendee.checkedInAt - ? new Date(attendee.checkedInAt).toLocaleString() - : "—"} -
-
- )} -
-
+ [ + ["events.listAll", undefined, c.events.listAll()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/admin/bootcamp/AdminBootcampPage.tsx b/sites/mainweb/app/(portal)/admin/bootcamp/AdminBootcampPage.tsx new file mode 100644 index 00000000..bd61f1c5 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/bootcamp/AdminBootcampPage.tsx @@ -0,0 +1,759 @@ +"use client"; + +import { venueFormat } from "@/lib/venue-time"; +import { useState } from "react"; +import { useSession } from "next-auth/react"; +import { Check, Copy, Mail } from "lucide-react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { BootcampMaterialsTable } from "@/components/portal/BootcampMaterialsTable"; +import type { BootcampMaterialRow } from "@/components/portal/BootcampMaterialsTable"; +import { BootcampWorkshopModal } from "@/components/portal/BootcampWorkshopModal"; +import type { BootcampWorkshopFormData } from "@/components/portal/BootcampWorkshopModal"; +import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; +import { useEventQR } from "@/components/portal/EventQR"; +import { trpc } from "@/lib/trpc"; +import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context"; +import { + body, + btnPrimary, + btnSecondary, + fieldLabel, + input, + itemTitle, + kicker, + label, + meta, + pageDek, + sectionTitle, + status as statusDot, + textLink, +} from "@/components/portal/ui"; + +/** Admin pages carry a smaller headline than member pages. */ +const adminTitle = + "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)]"; + +const th = "px-3 py-2.5 text-[13px] font-medium text-[var(--text-subtle)]"; + +/** `2026-fall` is how it is stored; nobody should have to read it that way. */ +function termLabel(term: string) { + const [year, season] = term.split("-"); + if (!year || !season) return term; + return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; +} + +/** `datetime-local` carries local wall-clock time rather than a UTC ISO value. */ +function toLocalInput(date: Date) { + const local = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000, + ); + return local.toISOString().slice(0, 16); +} + +/** A mailto with the whole cohort BCC'd stops being clickable somewhere around + * 2000 characters, and browsers differ on where. Past that, copying is the only + * thing that reliably works. */ +const MAILTO_LIMIT = 1800; + +function CohortEmails({ emails }: { emails: string[] }) { + const [copied, setCopied] = useState(false); + const list = emails.join(", "); + const mailto = `mailto:?bcc=${encodeURIComponent(emails.join(","))}`; + + const copy = async () => { + try { + await navigator.clipboard.writeText(list); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard is blocked without a secure context or permission; the + // addresses are on screen below either way. + setCopied(false); + } + }; + + return ( +
+
+
+

Email the cohort

+

+ {emails.length} address + {emails.length === 1 ? "" : "es"}, everyone enrolled this term. +

+
+ +
+ + + {mailto.length <= MAILTO_LIMIT && ( + + + )} +
+
+ + {/* Selectable as well as copyable: a locked-down browser refuses the + clipboard API, and this still works. */} +

+ {list} +

+ +

+ {copied ? "Addresses copied to the clipboard." : ""} +

+ + {mailto.length > MAILTO_LIMIT && ( +

+ Too many addresses for a mail-app link. Copy them and paste them into + BCC. +

+ )} +
+ ); +} + +function Stat({ + label: text, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
+
{text}
+
+ {value} +
+
+ ); +} + +export default function AdminBootcampPage() { + const { data: session, status } = useSession(); + const [term, setTerm] = useState(undefined); + const [modalOpen, setModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const utils = trpc.useUtils(); + const readOnly = useReadOnly(); + + const attendance = trpc.bootcamp.attendance.useQuery( + { term }, + { enabled: !!session }, + ); + const workshops = trpc.bootcamp.adminWorkshops.useQuery( + { term }, + { enabled: !!session }, + ); + const createWorkshop = trpc.bootcamp.createWorkshop.useMutation(); + const updateWorkshop = trpc.bootcamp.updateWorkshop.useMutation(); + const upsertSession = trpc.bootcamp.upsertSession.useMutation(); + + // Session check-in lives here rather than on the Club Hub: a session is + // still an event underneath, with the same QR and door, but staff run it + // from the bootcamp page. + const qr = useEventQR(); + const [attendanceFor, setAttendanceFor] = useState<{ + id: string; + title: string; + } | null>(null); + const toggleCheckIn = trpc.events.toggleCheckIn.useMutation({ + onSuccess: () => utils.bootcamp.attendance.invalidate(), + }); + const setPublished = trpc.bootcamp.setPublished.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.bootcamp.adminWorkshops.invalidate(), + utils.bootcamp.workshops.invalidate(), + ]); + }, + }); + + const upload = async ( + workshopId: string, + kind: "materials" | "solution", + file: File, + ) => { + const response = await fetch( + `/api/bootcamp/materials/${workshopId}/${kind}`, + { + method: "POST", + headers: { "x-bootcamp-filename": encodeURIComponent(file.name) }, + body: file, + }, + ); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? `Upload failed (${response.status})`); + } + }; + + const saveWorkshop = async (form: BootcampWorkshopFormData) => { + setIsSaving(true); + setError(null); + setNotice(null); + const wasEditing = !!selected; + // What the form opened with, so an untouched date or room is not rewritten. + const initialDate = selected?.eventDate + ? toLocalInput(new Date(selected.eventDate)) + : ""; + const initialLocation = selected?.location ?? ""; + const dateChanged = form.sessionDate !== initialDate; + const locationChanged = form.location.trim() !== initialLocation; + + let workshopId: string; + try { + const fields = { + title: form.title.trim(), + recordingUrl: form.recordingUrl.trim() || null, + }; + const saved = selected + ? await updateWorkshop.mutateAsync({ + workshopId: selected.id, + ...fields, + }) + : await createWorkshop.mutateAsync({ + ...fields, + week: Number(form.week), + // The term being viewed, not the live one: adding a week while + // filtered to a past cohort must land in that cohort. Undefined + // until the officer picks one, which the server reads as current. + term, + }); + workshopId = saved.id; + // A create followed by a failed upload must retry as an edit; otherwise + // the second save collides with the draft row that already succeeded. + setSelected({ + ...saved, + eventDate: selected?.eventDate ?? null, + location: selected?.location ?? null, + }); + } catch (cause) { + setError( + `Workshop details were not saved: ${cause instanceof Error ? cause.message : "Unknown error"}`, + ); + setIsSaving(false); + return; + } + + try { + // A blank date on a new row means TBA, not "detach week N's session". + if (dateChanged || (locationChanged && form.sessionDate)) { + await upsertSession.mutateAsync({ + workshopId, + sessionDate: form.sessionDate ? new Date(form.sessionDate) : null, + ...(locationChanged ? { location: form.location.trim() || null } : {}), + }); + } + // Session writes affect the material join, attendance views, and every + // event list that can expose the new QR-backed club event. + await Promise.all([ + utils.bootcamp.adminWorkshops.invalidate(), + utils.bootcamp.workshops.invalidate(), + utils.bootcamp.attendance.invalidate(), + utils.bootcamp.myProgress.invalidate(), + utils.events.listAll.invalidate(), + utils.events.list.invalidate(), + utils.events.myEvents.invalidate(), + utils.events.myStats.invalidate(), + ]); + } catch (cause) { + setError( + `Workshop details were saved, but its session event was not: ${cause instanceof Error ? cause.message : "Unknown error"}`, + ); + setIsSaving(false); + return; + } + + try { + if (form.materials) { + await upload(workshopId, "materials", form.materials); + } + if (form.solution) { + await upload(workshopId, "solution", form.solution); + } + } catch (cause) { + await utils.bootcamp.adminWorkshops.invalidate(); + setError( + `Workshop details were saved, but a ZIP upload failed: ${cause instanceof Error ? cause.message : "Unknown error"}`, + ); + setIsSaving(false); + return; + } + + await Promise.all([ + utils.bootcamp.adminWorkshops.invalidate(), + utils.bootcamp.workshops.invalidate(), + ]); + setNotice(wasEditing ? "Workshop updated." : "Workshop created as a draft."); + setIsSaving(false); + setModalOpen(false); + setSelected(null); + }; + + const removeFile = async (kind: "materials" | "solution") => { + if (!selected) return; + setIsSaving(true); + setError(null); + // A network failure otherwise leaves isSaving set and the modal stuck. + const response = await fetch( + `/api/bootcamp/materials/${selected.id}/${kind}`, + { method: "DELETE" }, + ).catch(() => null); + if (!response?.ok) { + const body = (await response?.json().catch(() => null)) as { + error?: string; + } | null; + setError(body?.error ?? `Could not remove ${kind}.`); + setIsSaving(false); + return; + } + setSelected({ + ...selected, + ...(kind === "materials" + ? { + materialsKey: null, + materialsFileName: null, + materialsSizeBytes: null, + } + : { + solutionKey: null, + solutionFileName: null, + solutionSizeBytes: null, + }), + }); + await utils.bootcamp.adminWorkshops.invalidate(); + setNotice(`${kind === "materials" ? "Materials" : "Solution"} ZIP removed.`); + setIsSaving(false); + }; + + const deleteWorkshop = async (row: BootcampMaterialRow) => { + if ( + !window.confirm( + `Delete week ${row.week}, its metadata, materials ZIP, and solution ZIP? This cannot be undone.`, + ) + ) { + return; + } + setError(null); + setNotice(null); + const response = await fetch(`/api/bootcamp/materials/${row.id}`, { + method: "DELETE", + }).catch(() => null); + if (!response?.ok) { + const body = (await response?.json().catch(() => null)) as { + error?: string; + } | null; + setError(body?.error ?? "Could not delete workshop."); + return; + } + await Promise.all([ + utils.bootcamp.adminWorkshops.invalidate(), + utils.bootcamp.workshops.invalidate(), + ]); + setNotice(`Week ${row.week} deleted.`); + }; + + if (status === "loading" || attendance.isPending || workshops.isPending) { + return ; + } + + if (attendance.error || workshops.error) { + return ( +
+

+ The bootcamp did not load:{" "} + {attendance.error?.message ?? workshops.error?.message} Reload the + page to try again. +

+
+ ); + } + + const data = attendance.data; + const { sessions, members, stats } = data; + + return ( +
+
+
+
+

{termLabel(data.term)}

+

Bootcamp

+

+ Who is enrolled, who turned up, and what each week hands out. +

+
+ + {/* Attendance outlives its semester, so past terms stay reachable. */} + {data.terms.length > 1 && ( +
+ + +
+ )} +
+ +
+ + + + +
+ +
+
+
+

Workshop materials

+

+ What members download each week. New weeks start as drafts. +

+
+ +
+ + {error && !modalOpen && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + + { + setSelected(row); + setError(null); + setModalOpen(true); + }} + onDelete={deleteWorkshop} + onSetPublished={async (row, isPublished) => { + setError(null); + setNotice(null); + try { + await setPublished.mutateAsync({ + workshopId: row.id, + isPublished, + }); + setNotice( + `Week ${row.week} ${isPublished ? "published" : "returned to draft"}.`, + ); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Could not change publication status.", + ); + } + }} + /> +
+ + {members.length > 0 && ( + member.email)} /> + )} + + {qr.modal} + {attendanceFor && ( + setAttendanceFor(null)} + /> + )} + +
+

Session check-in

+

+ Show the QR code at the door, then close check-in when the session + ends. +

+ + {sessions.length > 0 && ( +
    + {sessions.map((row) => ( +
  • +
    +
    + + Week {row.week} + + + {row.title} + + + {row.checkInEnabled ? "Check-in open" : "Check-in closed"} + +
    +

    + {row.location || "No room set"} ·{" "} + {venueFormat(row.eventDate)} ·{" "} + {row.attendance}{" "} + checked in +

    +
    + +
    + + + +
    +
  • + ))} +
+ )} + +

Attendance by member

+ + {sessions.length === 0 ? ( +

+ No sessions are scheduled for {termLabel(data.term)}. Give a + workshop a session date under Workshop materials, and its QR code, + check-in controls and attendance column appear here. +

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

+ Nobody has enrolled in this bootcamp yet. Members enrol by adding + the bootcamp to their membership payment, and they appear here as + soon as it clears. +

+ ) : ( + /* Twelve weeks will not fit a phone; scrolling beats squeezing. */ +
+ + + + + + {sessions.map((row) => ( + + ))} + + + + + {members.map((member) => { + const attended = new Set(member.attendedEventIds); + return ( + + + + {sessions.map((row) => ( + + ))} + + + + ); + })} + + + + + {sessions.map((row) => ( + + ))} + + +
+ Bootcamp attendance for {termLabel(data.term)}: one row per + enrolled member, one column per session. +
+ Member + + W{row.week} + + Total +
+ + {member.name} + + + {member.email} + + + {attended.has(row.id) ? ( + <> + + {member.attendedCount} +
+ Checked in + + {row.attendance} + +
+
+ )} + + {sessions.length > 0 && ( +

+ A session counts anyone who scanned in, member of this bootcamp or + not, so a per-session total can run ahead of the rows above. Fix a + wrong check-in from that session’s Attendance list. +

+ )} +
+ + {modalOpen && ( + { + if (isSaving) return; + setModalOpen(false); + setSelected(null); + setError(null); + }} + /> + )} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx b/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx index 3fdf90fe..faa72235 100644 --- a/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx +++ b/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx @@ -1,758 +1,15 @@ -"use client"; - -import { useState } from "react"; -import { useSession } from "next-auth/react"; -import { Check, Copy, Mail } from "lucide-react"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { BootcampMaterialsTable } from "@/components/portal/BootcampMaterialsTable"; -import type { BootcampMaterialRow } from "@/components/portal/BootcampMaterialsTable"; -import { BootcampWorkshopModal } from "@/components/portal/BootcampWorkshopModal"; -import type { BootcampWorkshopFormData } from "@/components/portal/BootcampWorkshopModal"; -import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; -import { useEventQR } from "@/components/portal/EventQR"; -import { trpc } from "@/lib/trpc"; -import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context"; -import { - body, - btnPrimary, - btnSecondary, - fieldLabel, - input, - itemTitle, - kicker, - label, - meta, - pageDek, - sectionTitle, - status as statusDot, - textLink, -} from "@/components/portal/ui"; - -/** Admin pages carry a smaller headline than member pages. */ -const adminTitle = - "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)]"; - -const th = "px-3 py-2.5 text-[13px] font-medium text-[var(--text-subtle)]"; - -/** `2026-fall` is how it is stored; nobody should have to read it that way. */ -function termLabel(term: string) { - const [year, season] = term.split("-"); - if (!year || !season) return term; - return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; -} - -/** `datetime-local` carries local wall-clock time rather than a UTC ISO value. */ -function toLocalInput(date: Date) { - const local = new Date( - date.getTime() - date.getTimezoneOffset() * 60 * 1000, - ); - return local.toISOString().slice(0, 16); -} - -/** A mailto with the whole cohort BCC'd stops being clickable somewhere around - * 2000 characters, and browsers differ on where. Past that, copying is the only - * thing that reliably works. */ -const MAILTO_LIMIT = 1800; - -function CohortEmails({ emails }: { emails: string[] }) { - const [copied, setCopied] = useState(false); - const list = emails.join(", "); - const mailto = `mailto:?bcc=${encodeURIComponent(emails.join(","))}`; - - const copy = async () => { - try { - await navigator.clipboard.writeText(list); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - } catch { - // Clipboard is blocked without a secure context or permission; the - // addresses are on screen below either way. - setCopied(false); - } - }; - - return ( -
-
-
-

Email the cohort

-

- {emails.length} address - {emails.length === 1 ? "" : "es"}, everyone enrolled this term. -

-
- -
- - - {mailto.length <= MAILTO_LIMIT && ( - - - )} -
-
- - {/* Selectable as well as copyable: a locked-down browser refuses the - clipboard API, and this still works. */} -

- {list} -

- -

- {copied ? "Addresses copied to the clipboard." : ""} -

- - {mailto.length > MAILTO_LIMIT && ( -

- Too many addresses for a mail-app link. Copy them and paste them into - BCC. -

- )} -
- ); -} - -function Stat({ - label: text, - value, -}: { - label: string; - value: string | number; -}) { - return ( -
-
{text}
-
- {value} -
-
- ); -} - -export default function AdminBootcampPage() { - const { data: session, status } = useSession(); - const [term, setTerm] = useState(undefined); - const [modalOpen, setModalOpen] = useState(false); - const [selected, setSelected] = useState(null); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - const utils = trpc.useUtils(); - const readOnly = useReadOnly(); - - const attendance = trpc.bootcamp.attendance.useQuery( - { term }, - { enabled: !!session }, - ); - const workshops = trpc.bootcamp.adminWorkshops.useQuery( - { term }, - { enabled: !!session }, - ); - const createWorkshop = trpc.bootcamp.createWorkshop.useMutation(); - const updateWorkshop = trpc.bootcamp.updateWorkshop.useMutation(); - const upsertSession = trpc.bootcamp.upsertSession.useMutation(); - - // Session check-in lives here rather than on the Club Hub: a session is - // still an event underneath, with the same QR and door, but staff run it - // from the bootcamp page. - const qr = useEventQR(); - const [attendanceFor, setAttendanceFor] = useState<{ - id: string; - title: string; - } | null>(null); - const toggleCheckIn = trpc.events.toggleCheckIn.useMutation({ - onSuccess: () => utils.bootcamp.attendance.invalidate(), - }); - const setPublished = trpc.bootcamp.setPublished.useMutation({ - onSuccess: async () => { - await Promise.all([ - utils.bootcamp.adminWorkshops.invalidate(), - utils.bootcamp.workshops.invalidate(), - ]); - }, - }); - - const upload = async ( - workshopId: string, - kind: "materials" | "solution", - file: File, - ) => { - const response = await fetch( - `/api/bootcamp/materials/${workshopId}/${kind}`, - { - method: "POST", - headers: { "x-bootcamp-filename": encodeURIComponent(file.name) }, - body: file, - }, - ); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(body?.error ?? `Upload failed (${response.status})`); - } - }; - - const saveWorkshop = async (form: BootcampWorkshopFormData) => { - setIsSaving(true); - setError(null); - setNotice(null); - const wasEditing = !!selected; - // What the form opened with, so an untouched date or room is not rewritten. - const initialDate = selected?.eventDate - ? toLocalInput(new Date(selected.eventDate)) - : ""; - const initialLocation = selected?.location ?? ""; - const dateChanged = form.sessionDate !== initialDate; - const locationChanged = form.location.trim() !== initialLocation; - - let workshopId: string; - try { - const fields = { - title: form.title.trim(), - recordingUrl: form.recordingUrl.trim() || null, - }; - const saved = selected - ? await updateWorkshop.mutateAsync({ - workshopId: selected.id, - ...fields, - }) - : await createWorkshop.mutateAsync({ - ...fields, - week: Number(form.week), - // The term being viewed, not the live one: adding a week while - // filtered to a past cohort must land in that cohort. Undefined - // until the officer picks one, which the server reads as current. - term, - }); - workshopId = saved.id; - // A create followed by a failed upload must retry as an edit; otherwise - // the second save collides with the draft row that already succeeded. - setSelected({ - ...saved, - eventDate: selected?.eventDate ?? null, - location: selected?.location ?? null, - }); - } catch (cause) { - setError( - `Workshop details were not saved: ${cause instanceof Error ? cause.message : "Unknown error"}`, - ); - setIsSaving(false); - return; - } - - try { - // A blank date on a new row means TBA, not "detach week N's session". - if (dateChanged || (locationChanged && form.sessionDate)) { - await upsertSession.mutateAsync({ - workshopId, - sessionDate: form.sessionDate ? new Date(form.sessionDate) : null, - ...(locationChanged ? { location: form.location.trim() || null } : {}), - }); - } - // Session writes affect the material join, attendance views, and every - // event list that can expose the new QR-backed club event. - await Promise.all([ - utils.bootcamp.adminWorkshops.invalidate(), - utils.bootcamp.workshops.invalidate(), - utils.bootcamp.attendance.invalidate(), - utils.bootcamp.myProgress.invalidate(), - utils.events.listAll.invalidate(), - utils.events.list.invalidate(), - utils.events.myEvents.invalidate(), - utils.events.myStats.invalidate(), - ]); - } catch (cause) { - setError( - `Workshop details were saved, but its session event was not: ${cause instanceof Error ? cause.message : "Unknown error"}`, - ); - setIsSaving(false); - return; - } - - try { - if (form.materials) { - await upload(workshopId, "materials", form.materials); - } - if (form.solution) { - await upload(workshopId, "solution", form.solution); - } - } catch (cause) { - await utils.bootcamp.adminWorkshops.invalidate(); - setError( - `Workshop details were saved, but a ZIP upload failed: ${cause instanceof Error ? cause.message : "Unknown error"}`, - ); - setIsSaving(false); - return; - } - - await Promise.all([ - utils.bootcamp.adminWorkshops.invalidate(), - utils.bootcamp.workshops.invalidate(), - ]); - setNotice(wasEditing ? "Workshop updated." : "Workshop created as a draft."); - setIsSaving(false); - setModalOpen(false); - setSelected(null); - }; - - const removeFile = async (kind: "materials" | "solution") => { - if (!selected) return; - setIsSaving(true); - setError(null); - // A network failure otherwise leaves isSaving set and the modal stuck. - const response = await fetch( - `/api/bootcamp/materials/${selected.id}/${kind}`, - { method: "DELETE" }, - ).catch(() => null); - if (!response?.ok) { - const body = (await response?.json().catch(() => null)) as { - error?: string; - } | null; - setError(body?.error ?? `Could not remove ${kind}.`); - setIsSaving(false); - return; - } - setSelected({ - ...selected, - ...(kind === "materials" - ? { - materialsKey: null, - materialsFileName: null, - materialsSizeBytes: null, - } - : { - solutionKey: null, - solutionFileName: null, - solutionSizeBytes: null, - }), - }); - await utils.bootcamp.adminWorkshops.invalidate(); - setNotice(`${kind === "materials" ? "Materials" : "Solution"} ZIP removed.`); - setIsSaving(false); - }; - - const deleteWorkshop = async (row: BootcampMaterialRow) => { - if ( - !window.confirm( - `Delete week ${row.week}, its metadata, materials ZIP, and solution ZIP? This cannot be undone.`, - ) - ) { - return; - } - setError(null); - setNotice(null); - const response = await fetch(`/api/bootcamp/materials/${row.id}`, { - method: "DELETE", - }).catch(() => null); - if (!response?.ok) { - const body = (await response?.json().catch(() => null)) as { - error?: string; - } | null; - setError(body?.error ?? "Could not delete workshop."); - return; - } - await Promise.all([ - utils.bootcamp.adminWorkshops.invalidate(), - utils.bootcamp.workshops.invalidate(), - ]); - setNotice(`Week ${row.week} deleted.`); - }; - - if (status === "loading" || attendance.isPending || workshops.isPending) { - return ; - } - - if (attendance.error || workshops.error) { - return ( -
-

- The bootcamp did not load:{" "} - {attendance.error?.message ?? workshops.error?.message} Reload the - page to try again. -

-
- ); - } - - const data = attendance.data; - const { sessions, members, stats } = data; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AdminBootcampPage from "./AdminBootcampPage"; +export default function Page() { return ( -
-
-
-
-

{termLabel(data.term)}

-

Bootcamp

-

- Who is enrolled, who turned up, and what each week hands out. -

-
- - {/* Attendance outlives its semester, so past terms stay reachable. */} - {data.terms.length > 1 && ( -
- - -
- )} -
- -
- - - - -
- -
-
-
-

Workshop materials

-

- What members download each week. New weeks start as drafts. -

-
- -
- - {error && !modalOpen && ( -

- {error} -

- )} - {notice && ( -

- {notice} -

- )} - - { - setSelected(row); - setError(null); - setModalOpen(true); - }} - onDelete={deleteWorkshop} - onSetPublished={async (row, isPublished) => { - setError(null); - setNotice(null); - try { - await setPublished.mutateAsync({ - workshopId: row.id, - isPublished, - }); - setNotice( - `Week ${row.week} ${isPublished ? "published" : "returned to draft"}.`, - ); - } catch (cause) { - setError( - cause instanceof Error - ? cause.message - : "Could not change publication status.", - ); - } - }} - /> -
- - {members.length > 0 && ( - member.email)} /> - )} - - {qr.modal} - {attendanceFor && ( - setAttendanceFor(null)} - /> - )} - -
-

Session check-in

-

- Show the QR code at the door, then close check-in when the session - ends. -

- - {sessions.length > 0 && ( -
    - {sessions.map((row) => ( -
  • -
    -
    - - Week {row.week} - - - {row.title} - - - {row.checkInEnabled ? "Check-in open" : "Check-in closed"} - -
    -

    - {row.location || "No room set"} ·{" "} - {new Date(row.eventDate).toLocaleDateString()} ·{" "} - {row.attendance}{" "} - checked in -

    -
    - -
    - - - -
    -
  • - ))} -
- )} - -

Attendance by member

- - {sessions.length === 0 ? ( -

- No sessions are scheduled for {termLabel(data.term)}. Give a - workshop a session date under Workshop materials, and its QR code, - check-in controls and attendance column appear here. -

- ) : members.length === 0 ? ( -

- Nobody has enrolled in this bootcamp yet. Members enrol by adding - the bootcamp to their membership payment, and they appear here as - soon as it clears. -

- ) : ( - /* Twelve weeks will not fit a phone; scrolling beats squeezing. */ -
- - - - - - {sessions.map((row) => ( - - ))} - - - - - {members.map((member) => { - const attended = new Set(member.attendedEventIds); - return ( - - - - {sessions.map((row) => ( - - ))} - - - - ); - })} - - - - - {sessions.map((row) => ( - - ))} - - -
- Bootcamp attendance for {termLabel(data.term)}: one row per - enrolled member, one column per session. -
- Member - - W{row.week} - - Total -
- - {member.name} - - - {member.email} - - - {attended.has(row.id) ? ( - <> - - {member.attendedCount} -
- Checked in - - {row.attendance} - -
-
- )} - - {sessions.length > 0 && ( -

- A session counts anyone who scanned in, member of this bootcamp or - not, so a per-session total can run ahead of the rows above. Fix a - wrong check-in from that session’s Attendance list. -

- )} -
- - {modalOpen && ( - { - if (isSaving) return; - setModalOpen(false); - setSelected(null); - setError(null); - }} - /> - )} -
-
+ [ + ["bootcamp.attendance", { term: undefined }, c.bootcamp.attendance({ term: undefined })], + ["bootcamp.adminWorkshops", { term: undefined }, c.bootcamp.adminWorkshops({ term: undefined })], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/admin/hackathons/AdminHackathonsPage.tsx b/sites/mainweb/app/(portal)/admin/hackathons/AdminHackathonsPage.tsx new file mode 100644 index 00000000..c184fdb1 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/hackathons/AdminHackathonsPage.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { loginHref } from "@/lib/safe-callback"; +import { trpc } from "@/lib/trpc"; +import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { adminHackathonPath } from "@/lib/hackathon-slug"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { body, btnPrimary, btnSecondary, pageDek } from "@/components/portal/ui"; + +// Extracted Components + +import { HackathonCard } from "@/components/admin/hackathons/HackathonCard"; +import { CreateHackathonForm } from "@/components/admin/hackathons/CreateHackathonForm"; +import { EditHackathonForm } from "@/components/admin/hackathons/EditHackathonForm"; +import { HackathonBans } from "@/components/admin/hackathons/HackathonBans"; + +const adminTitle = + "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text-primary)]"; + +export default function AdminHackathonsPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const utils = trpc.useUtils(); + const readOnly = useReadOnly(); + + const [showCreate, setShowCreate] = useState(false); + const [editingId, setEditingId] = useState(null); + + const { + data: hackathons, + isLoading, + isError, + error, + refetch, + } = trpc.hackathon.listAll.useQuery( + undefined, + { enabled: !!session }, + ); + + if (status === "loading") return ; + if (status === "unauthenticated") { + router.push(loginHref()); + return null; + } + + return ( + <> +
+
+
+

Hackathons

+

+ Every edition, its schedule and who has signed up. Open one to + review applications, check people in and run judging. +

+
+ +
+ + {showCreate && ( + setShowCreate(false)} + onCreated={(row) => { + setShowCreate(false); + utils.hackathon.listAll.invalidate(); + // Straight into the new edition's dashboard, where its + // schedule, applications and judging are set up. + router.push(adminHackathonPath(row.name, row.id)); + }} + /> + )} + + {editingId && ( + setEditingId(null)} + onSaved={() => { + // The form seeds itself from getById; left cached, reopening it + // shows the old values and a second save writes them back. + utils.hackathon.getById.invalidate({ id: editingId }); + setEditingId(null); + utils.hackathon.listAll.invalidate(); + }} + /> + )} + +
+ {/* Checked before the skeleton: a failed query leaves data + undefined, which used to hold the skeleton up forever. */} + {isError ? ( +
+

+ Couldn't load hackathons. {error.message} +

+ +
+ ) : isLoading || hackathons === undefined ? ( +
+ {[1, 2, 3].map((n) => ( +
+ ))} +
+ ) : hackathons.length === 0 ? ( +
+

+ No hackathons yet. Each edition you create will be listed here. +

+ +
+ ) : ( +
+ {hackathons.map((h: NonNullable[number]) => { + return ( + setEditingId(h.id)} + onStatusChange={(_newStatus) => { + utils.hackathon.listAll.invalidate(); + }} + /> + ); + })} +
+ )} + + +
+
+ + ); +} diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/AdminHackathonPage.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/AdminHackathonPage.tsx new file mode 100644 index 00000000..cafb6440 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/AdminHackathonPage.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { trpc } from "@/lib/trpc"; +import { + READ_ONLY_TITLE, + canViewAdmin, + usePortalContext, + useReadOnly, +} from "@/lib/use-portal-context"; +import { useParams, useRouter } from "next/navigation"; +import { useState, useSyncExternalStore } from "react"; +import Link from "next/link"; +import { adminHackathonPath, decodeHackathonParam } from "@/lib/hackathon-slug"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { EditHackathonForm } from "@/components/admin/hackathons/EditHackathonForm"; +import { ScannerTab } from "@/components/admin/hackathons/ScannerTab"; +import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab"; +import { AnalyticsTab } from "@/components/admin/hackathons/AnalyticsTab"; +import { EventsTab } from "@/components/admin/hackathons/EventsTab"; +import { JudgesTab } from "@/components/admin/hackathons/JudgesTab"; +import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab"; +import { PointsAdminTab } from "@/components/admin/hackathons/points/PointsAdminTab"; +import { StoreTab } from "@/components/admin/hackathons/points/StoreTab"; +import { + body, + btnPrimary, + btnSecondary, + sectionTitle, + tab as tabClass, + tabList, +} from "@/components/portal/ui"; +import { + BarChart3, + Calendar, + Coins, + Gavel, + Megaphone, + ScanLine, + Store, + Users, +} from "lucide-react"; + +const TABS = [ + "events", + "scanner", + "attendees", + "analytics", + "judges", + "points", + "store", + "announcements", +] as const; + +type Tab = (typeof TABS)[number]; + +// The tab lives in the URL hash, like the judging console: a reload, a pasted +// link and the schedule links on the hackathons list (#events) land on the +// tab the organiser meant instead of Applications. +const subscribeHash = (onChange: () => void) => { + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); +}; + +function useTab(): Tab { + const hash = useSyncExternalStore( + subscribeHash, + () => window.location.hash.slice(1), + () => "", + ); + return TABS.find((t) => t === hash) ?? "attendees"; +} + +export default function AdminHackathonDashboard() { + const { status } = useSession(); + const params = useParams(); + const router = useRouter(); + const utils = trpc.useUtils(); + const readOnly = useReadOnly(); + const rawId = params?.id; + const hackathonId = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); + const activeTab = useTab(); + const [editing, setEditing] = useState(false); + + const { data: portalContext, isLoading: portalLoading } = usePortalContext(); + const { + data: hackathon, + isLoading, + error, + refetch, + } = trpc.hackathon.getById.useQuery( + { id: hackathonId }, + { enabled: !!hackathonId && canViewAdmin(portalContext) }, + ); + + // isLoading is false on the render where the query flips enabled but has not + // started fetching, and it is false again once the query has errored. Both + // used to fall through to a bare `return null`, which paints the dashboard as + // an unexplained black screen instead of a spinner or the actual failure. + const settled = !!hackathon || !!error; + + if (status === "loading" || portalLoading) { + return ; + } + + // Not found rather than a permission error: telling a non-admin that this + // edition exists behind a door they cannot open is the whole leak, and the + // query is disabled for them anyway, so waiting on it spun forever. + if (!canViewAdmin(portalContext)) { + return ; + } + + if (isLoading || !settled) { + return ; + } + + if (!hackathon) { + return ( + refetch()} + /> + ); + } + + // A rename moves the dashboard to a new slug and the old one stops + // resolving, so follow the row to its new path instead of landing on + // "Hackathon not found" the moment the save succeeds. + const finishEdit = async () => { + setEditing(false); + utils.hackathon.listAll.invalidate(); + try { + const fresh = await utils.hackathon.getById.fetch( + { id: hackathon.id }, + { staleTime: 0 }, + ); + const next = adminHackathonPath(fresh.name, fresh.id); + if (next !== window.location.pathname) { + router.replace(`${next}${window.location.hash}`); + return; + } + } catch { + // Fall through: the invalidation below surfaces the failure on the page. + } + utils.hackathon.getById.invalidate(); + }; + + const icon = { className: "w-4 h-4", strokeWidth: 1.75, "aria-hidden": true } as const; + const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { + id: "attendees", + label: "Applications", + icon: , + }, + { + id: "events", + label: "Events", + icon: , + }, + { id: "scanner", label: "Scan", icon: }, + { + id: "analytics", + label: "Stats", + icon: , + }, + { id: "judges", label: "Judges", icon: }, + { id: "points", label: "Points", icon: }, + { id: "store", label: "Store", icon: }, + { + id: "announcements", + label: "Email", + icon: , + }, + ]; + + return ( +
+
+
+
+
+ + ← All hackathons + +

+ {hackathon.name} +

+
+
+ {["scanner", "attendees"].includes(activeTab) && ( + // ScannerTab is rendered on this page; there is no + // /admin/hackathons/[id]/scanner route to link to. + + + )} + +
+
+ + {/* DESKTOP TABS - Hidden on mobile */} +
+ {tabs.map((tab) => ( + + {tab.label} + + ))} +
+
+
+ + {/* MAIN CONTENT AREA */} +
+
+ {activeTab === "events" && } + {activeTab === "scanner" && } + {activeTab === "attendees" && ( + + )} + {activeTab === "analytics" && ( + + )} + {activeTab === "judges" && } + {activeTab === "points" && ( + + )} + {activeTab === "store" && } + {activeTab === "announcements" && ( + + )} +
+
+ + {/* MOBILE BOTTOM NAVIGATION */} + {/* Scrolls horizontally rather than a fixed 5-column grid: there are + eight tabs, so the last ones would be laid out off the right edge of + the phone an organiser actually carries — unreachable, with nothing + to suggest they existed. */} +
+ {tabs.map((tab) => ( + + {tab.icon} + {tab.label} + + ))} +
+ + {editing && ( + setEditing(false)} + onSaved={finishEdit} + /> + )} +
+ ); +} + +function DashboardUnavailable({ + message, + onRetry, +}: { + message: string; + onRetry?: () => void; +}) { + return ( +
+
+

Hackathon unavailable

+

{message}

+
+ {onRetry && ( + + )} + + Back to hackathons + +
+
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index cafb6440..ec8ad6e8 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -1,327 +1,46 @@ -"use client"; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; +import AdminHackathonDashboard from "./AdminHackathonPage"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { - READ_ONLY_TITLE, - canViewAdmin, - usePortalContext, - useReadOnly, -} from "@/lib/use-portal-context"; -import { useParams, useRouter } from "next/navigation"; -import { useState, useSyncExternalStore } from "react"; -import Link from "next/link"; -import { adminHackathonPath, decodeHackathonParam } from "@/lib/hackathon-slug"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { EditHackathonForm } from "@/components/admin/hackathons/EditHackathonForm"; -import { ScannerTab } from "@/components/admin/hackathons/ScannerTab"; -import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab"; -import { AnalyticsTab } from "@/components/admin/hackathons/AnalyticsTab"; -import { EventsTab } from "@/components/admin/hackathons/EventsTab"; -import { JudgesTab } from "@/components/admin/hackathons/JudgesTab"; -import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab"; -import { PointsAdminTab } from "@/components/admin/hackathons/points/PointsAdminTab"; -import { StoreTab } from "@/components/admin/hackathons/points/StoreTab"; -import { - body, - btnPrimary, - btnSecondary, - sectionTitle, - tab as tabClass, - tabList, -} from "@/components/portal/ui"; -import { - BarChart3, - Calendar, - Coins, - Gavel, - Megaphone, - ScanLine, - Store, - Users, -} from "lucide-react"; +// The Applications tab is what the dashboard opens on; a link to another tab +// carries it in the hash, which the server never sees, so that tab fetches for +// itself as before. +const PAGE_SIZE = 50; -const TABS = [ - "events", - "scanner", - "attendees", - "analytics", - "judges", - "points", - "store", - "announcements", -] as const; - -type Tab = (typeof TABS)[number]; - -// The tab lives in the URL hash, like the judging console: a reload, a pasted -// link and the schedule links on the hackathons list (#events) land on the -// tab the organiser meant instead of Applications. -const subscribeHash = (onChange: () => void) => { - window.addEventListener("hashchange", onChange); - return () => window.removeEventListener("hashchange", onChange); -}; - -function useTab(): Tab { - const hash = useSyncExternalStore( - subscribeHash, - () => window.location.hash.slice(1), - () => "", - ); - return TABS.find((t) => t === hash) ?? "attendees"; -} - -export default function AdminHackathonDashboard() { - const { status } = useSession(); - const params = useParams(); - const router = useRouter(); - const utils = trpc.useUtils(); - const readOnly = useReadOnly(); - const rawId = params?.id; - const hackathonId = decodeHackathonParam( - Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), - ); - const activeTab = useTab(); - const [editing, setEditing] = useState(false); - - const { data: portalContext, isLoading: portalLoading } = usePortalContext(); - const { - data: hackathon, - isLoading, - error, - refetch, - } = trpc.hackathon.getById.useQuery( - { id: hackathonId }, - { enabled: !!hackathonId && canViewAdmin(portalContext) }, - ); - - // isLoading is false on the render where the query flips enabled but has not - // started fetching, and it is false again once the query has errored. Both - // used to fall through to a bare `return null`, which paints the dashboard as - // an unexplained black screen instead of a spinner or the actual failure. - const settled = !!hackathon || !!error; - - if (status === "loading" || portalLoading) { - return ; - } - - // Not found rather than a permission error: telling a non-admin that this - // edition exists behind a door they cannot open is the whole leak, and the - // query is disabled for them anyway, so waiting on it spun forever. - if (!canViewAdmin(portalContext)) { - return ; - } - - if (isLoading || !settled) { - return ; - } - - if (!hackathon) { - return ( - refetch()} - /> - ); - } - - // A rename moves the dashboard to a new slug and the old one stops - // resolving, so follow the row to its new path instead of landing on - // "Hackathon not found" the moment the save succeeds. - const finishEdit = async () => { - setEditing(false); - utils.hackathon.listAll.invalidate(); - try { - const fresh = await utils.hackathon.getById.fetch( - { id: hackathon.id }, - { staleTime: 0 }, - ); - const next = adminHackathonPath(fresh.name, fresh.id); - if (next !== window.location.pathname) { - router.replace(`${next}${window.location.hash}`); - return; - } - } catch { - // Fall through: the invalidation below surfaces the failure on the page. - } - utils.hackathon.getById.invalidate(); - }; - - const icon = { className: "w-4 h-4", strokeWidth: 1.75, "aria-hidden": true } as const; - const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ - { - id: "attendees", - label: "Applications", - icon: , - }, - { - id: "events", - label: "Events", - icon: , - }, - { id: "scanner", label: "Scan", icon: }, - { - id: "analytics", - label: "Stats", - icon: , - }, - { id: "judges", label: "Judges", icon: }, - { id: "points", label: "Points", icon: }, - { id: "store", label: "Store", icon: }, - { - id: "announcements", - label: "Email", - icon: , - }, - ]; - - return ( -
-
-
-
-
- - ← All hackathons - -

- {hackathon.name} -

-
-
- {["scanner", "attendees"].includes(activeTab) && ( - // ScannerTab is rendered on this page; there is no - // /admin/hackathons/[id]/scanner route to link to. - - - )} - -
-
- - {/* DESKTOP TABS - Hidden on mobile */} -
- {tabs.map((tab) => ( - - {tab.label} - - ))} -
-
-
- - {/* MAIN CONTENT AREA */} -
-
- {activeTab === "events" && } - {activeTab === "scanner" && } - {activeTab === "attendees" && ( - - )} - {activeTab === "analytics" && ( - - )} - {activeTab === "judges" && } - {activeTab === "points" && ( - - )} - {activeTab === "store" && } - {activeTab === "announcements" && ( - - )} -
-
- - {/* MOBILE BOTTOM NAVIGATION */} - {/* Scrolls horizontally rather than a fixed 5-column grid: there are - eight tabs, so the last ones would be laid out off the right edge of - the phone an organiser actually carries — unreachable, with nothing - to suggest they existed. */} -
- {tabs.map((tab) => ( - - {tab.icon} - {tab.label} - - ))} -
- - {editing && ( - setEditing(false)} - onSaved={finishEdit} - /> - )} -
- ); -} - -function DashboardUnavailable({ - message, - onRetry, +export default async function Page({ + params, }: { - message: string; - onRetry?: () => void; + params: Promise<{ id: string }>; }) { + const id = decodeHackathonParam((await params).id); return ( -
-
-

Hackathon unavailable

-

{message}

-
- {onRetry && ( - - )} - - Back to hackathons - -
-
-
+ { + const hackathon = c.hackathon.getById({ id }); + const found = await hackathon.catch(() => null); + if (!found) return [["hackathon.getById", { id }, hackathon]]; + const hackathonId = found.id; + return [ + ["hackathon.getById", { id }, hackathon], + ["hackathon.getById", { id: hackathonId }, hackathon], + [ + "hackathon.adminGetAttendees", + { hackathonId, limit: PAGE_SIZE, offset: 0 }, + c.hackathon.adminGetAttendees({ + hackathonId, + limit: PAGE_SIZE, + offset: 0, + }), + ], + [ + "hackathon.analytics", + { hackathonId }, + c.hackathon.analytics({ hackathonId }), + ], + ]; + }} + > + + ); } diff --git a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx index c184fdb1..7c654d88 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx @@ -1,160 +1,15 @@ -"use client"; - -import { useSession } from "next-auth/react"; -import { loginHref } from "@/lib/safe-callback"; -import { trpc } from "@/lib/trpc"; -import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { adminHackathonPath } from "@/lib/hackathon-slug"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { body, btnPrimary, btnSecondary, pageDek } from "@/components/portal/ui"; - -// Extracted Components - -import { HackathonCard } from "@/components/admin/hackathons/HackathonCard"; -import { CreateHackathonForm } from "@/components/admin/hackathons/CreateHackathonForm"; -import { EditHackathonForm } from "@/components/admin/hackathons/EditHackathonForm"; -import { HackathonBans } from "@/components/admin/hackathons/HackathonBans"; - -const adminTitle = - "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text-primary)]"; - -export default function AdminHackathonsPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const utils = trpc.useUtils(); - const readOnly = useReadOnly(); - - const [showCreate, setShowCreate] = useState(false); - const [editingId, setEditingId] = useState(null); - - const { - data: hackathons, - isLoading, - isError, - error, - refetch, - } = trpc.hackathon.listAll.useQuery( - undefined, - { enabled: !!session }, - ); - - if (status === "loading") return ; - if (status === "unauthenticated") { - router.push(loginHref()); - return null; - } +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AdminHackathonsPage from "./AdminHackathonsPage"; +export default function Page() { return ( - <> -
-
-
-

Hackathons

-

- Every edition, its schedule and who has signed up. Open one to - review applications, check people in and run judging. -

-
- -
- - {showCreate && ( - setShowCreate(false)} - onCreated={(row) => { - setShowCreate(false); - utils.hackathon.listAll.invalidate(); - // Straight into the new edition's dashboard, where its - // schedule, applications and judging are set up. - router.push(adminHackathonPath(row.name, row.id)); - }} - /> - )} - - {editingId && ( - setEditingId(null)} - onSaved={() => { - // The form seeds itself from getById; left cached, reopening it - // shows the old values and a second save writes them back. - utils.hackathon.getById.invalidate({ id: editingId }); - setEditingId(null); - utils.hackathon.listAll.invalidate(); - }} - /> - )} - -
- {/* Checked before the skeleton: a failed query leaves data - undefined, which used to hold the skeleton up forever. */} - {isError ? ( -
-

- Couldn't load hackathons. {error.message} -

- -
- ) : isLoading || hackathons === undefined ? ( -
- {[1, 2, 3].map((n) => ( -
- ))} -
- ) : hackathons.length === 0 ? ( -
-

- No hackathons yet. Each edition you create will be listed here. -

- -
- ) : ( -
- {hackathons.map((h: NonNullable[number]) => { - return ( - setEditingId(h.id)} - onStatusChange={(_newStatus) => { - utils.hackathon.listAll.invalidate(); - }} - /> - ); - })} -
- )} - - -
-
- + [ + ["hackathon.listAll", undefined, c.hackathon.listAll()], + ["hackathon.listBans", undefined, c.hackathon.listBans()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/admin/judging/AdminJudgingPage.tsx b/sites/mainweb/app/(portal)/admin/judging/AdminJudgingPage.tsx new file mode 100644 index 00000000..8917f061 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/AdminJudgingPage.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { venueFormat } from "@/lib/venue-time"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { hackathonStatus } from "@/lib/hackathon-status"; +import { + body, + itemTitle, + meta, + pageDek, + sectionRule, + status, + textLink, +} from "@/components/portal/ui"; + +// Judging is run per edition. AdminLayout gates this page to staff. +export default function AdminJudgingPage() { + const { data: hackathons, isLoading, isError, refetch } = + trpc.hackathon.list.useQuery({ limit: 50 }); + + return ( +
+
+

+ Judging +

+

+ Pick an edition to open its judging console: projects, challenges, + rooms, the schedule, launch and results. Each edition's judge + roster is on its hackathon page, under Judges. +

+
+ +
+ {isLoading ? ( +

Loading editions…

+ ) : isError ? ( +

+ Could not load the editions.{" "} + +

+ ) : !hackathons || hackathons.length === 0 ? ( +

+ No editions yet. Create one from{" "} + + Hackathons + {" "} + first. +

+ ) : ( +
    + {hackathons.map((h) => { + const conf = hackathonStatus(h.status); + return ( +
  • + +
    +

    {h.name}

    +

    + {conf.label} + {" · "} + {venueFormat(h.startDate, { + month: "short", + day: "numeric", + year: "numeric", + })} +

    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/AdminEditionJudgingPage.tsx b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/AdminEditionJudgingPage.tsx new file mode 100644 index 00000000..7a1dc58b --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/AdminEditionJudgingPage.tsx @@ -0,0 +1,220 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { trpc } from "@/lib/trpc"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { + body, + btnSecondary, + kicker, + meta, + status, + tab, + tabList, +} from "@/components/portal/ui"; +import type { Tone } from "@/components/portal/ui"; +import { + OFFICER_POLL_MS, + SECTIONS, + plural, + stationCount, +} from "@/components/admin/judging/shared"; +import type { Overview, SectionId } from "@/components/admin/judging/shared"; +import { ChecklistSection } from "@/components/admin/judging/ChecklistSection"; +import { ProjectsSection } from "@/components/admin/judging/ProjectsSection"; +import { ChallengesSection } from "@/components/admin/judging/ChallengesSection"; +import { RoomsSection } from "@/components/admin/judging/RoomsSection"; +import { RubricSection } from "@/components/admin/judging/RubricSection"; +import { LaunchSection } from "@/components/admin/judging/LaunchSection"; +import { AnnouncementsSection } from "@/components/admin/judging/AnnouncementsSection"; +import { RepoCheckSection } from "@/components/admin/judging/RepoCheckSection"; +import { DangerZone } from "@/components/admin/judging/DangerZone"; +import { ScheduleTab as JudgingSchedule } from "@/components/admin/judging/schedule/ScheduleTab"; +import { ClaimsPanel as JudgingClaims } from "@/components/admin/judging/claims/ClaimsPanel"; +import { ResultsTab as JudgingResults } from "@/components/admin/judging/results/ResultsTab"; + +// One edition's judging console. The section lives in the URL hash so the +// checklist can link straight to the part that needs work and a reload keeps +// the organiser where they were. AdminLayout gates this page to staff. + +const subscribeHash = (onChange: () => void) => { + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); +}; + +function useSection(): SectionId { + const hash = useSyncExternalStore( + subscribeHash, + () => window.location.hash.slice(1), + () => "", + ); + return SECTIONS.find((s) => s.id === hash)?.id ?? "checklist"; +} + +const STATE: Record = + { + draft: { label: "Draft", tone: "neutral" }, + open: { label: "Open", tone: "success" }, + closed: { label: "Closed", tone: "warning" }, + }; + +export default function AdminEditionJudgingPage() { + const params = useParams<{ hackathonId: string }>(); + const hackathonId = params?.hackathonId ?? ""; + const section = useSection(); + + const overview = trpc.judging.admin.overview.useQuery( + { hackathonId }, + { enabled: !!hackathonId, refetchInterval: OFFICER_POLL_MS }, + ); + + // Switching from far down a long section leaves the new one scrolled out of + // sight on a phone; bring its top back into view. + const contentRef = useRef(null); + const shown = useRef(section); + useEffect(() => { + if (shown.current === section) return; + shown.current = section; + const top = contentRef.current?.getBoundingClientRect().top ?? 0; + if (top < 0) contentRef.current?.scrollIntoView({ block: "start" }); + }, [section]); + + const data = overview.data; + + return ( +
+ + ← All editions + +
+ {data &&

{data.hackathon.name}

} +

+ Judging +

+ {data ? ( +

+ + Judging {STATE[data.config.state].label.toLowerCase()} + + + {plural( + data.counts.entries.devpost + data.counts.entries.portal, + "project", + )} + + + {stationCount(data.counts.rooms.active, data.counts.rooms.tables)} + + {plural(data.counts.judgesSeated, "judge")} seated + {data.counts.checkInFlags.open > 0 && ( + + {plural(data.counts.checkInFlags.open, "open check-in flag")} + + )} + {data.config.emergencyMode && ( + Emergency mode on + )} +

+ ) : null} +
+ + {overview.isLoading ? ( +

Loading the edition…

+ ) : overview.isError || !data ? ( +
+

+ {trpcErrorMessage(overview.error, "Could not load this edition.")} +

+
+ + + Pick another edition + +
+
+ ) : ( +
+ + +
+ {section === "checklist" && ( + + )} + {section === "projects" && ( + + )} + {section === "challenges" && ( + + )} + {section === "rooms" && ( + + )} + {section === "rubric" && } + {section === "launch" && ( + + )} + {section === "schedule" && ( + + )} + {section === "claims" && } + {section === "results" && ( + + )} + {section === "repo-check" && ( + + )} + {section === "announcements" && ( + + )} + {section === "danger" && ( + + )} +
+
+ )} +
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx index 7a1dc58b..0e9928cb 100644 --- a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx @@ -1,220 +1,28 @@ -"use client"; - -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { useEffect, useRef, useSyncExternalStore } from "react"; -import { trpc } from "@/lib/trpc"; -import { trpcErrorMessage } from "@/lib/trpc-error"; -import { - body, - btnSecondary, - kicker, - meta, - status, - tab, - tabList, -} from "@/components/portal/ui"; -import type { Tone } from "@/components/portal/ui"; -import { - OFFICER_POLL_MS, - SECTIONS, - plural, - stationCount, -} from "@/components/admin/judging/shared"; -import type { Overview, SectionId } from "@/components/admin/judging/shared"; -import { ChecklistSection } from "@/components/admin/judging/ChecklistSection"; -import { ProjectsSection } from "@/components/admin/judging/ProjectsSection"; -import { ChallengesSection } from "@/components/admin/judging/ChallengesSection"; -import { RoomsSection } from "@/components/admin/judging/RoomsSection"; -import { RubricSection } from "@/components/admin/judging/RubricSection"; -import { LaunchSection } from "@/components/admin/judging/LaunchSection"; -import { AnnouncementsSection } from "@/components/admin/judging/AnnouncementsSection"; -import { RepoCheckSection } from "@/components/admin/judging/RepoCheckSection"; -import { DangerZone } from "@/components/admin/judging/DangerZone"; -import { ScheduleTab as JudgingSchedule } from "@/components/admin/judging/schedule/ScheduleTab"; -import { ClaimsPanel as JudgingClaims } from "@/components/admin/judging/claims/ClaimsPanel"; -import { ResultsTab as JudgingResults } from "@/components/admin/judging/results/ResultsTab"; - -// One edition's judging console. The section lives in the URL hash so the -// checklist can link straight to the part that needs work and a reload keeps -// the organiser where they were. AdminLayout gates this page to staff. - -const subscribeHash = (onChange: () => void) => { - window.addEventListener("hashchange", onChange); - return () => window.removeEventListener("hashchange", onChange); -}; - -function useSection(): SectionId { - const hash = useSyncExternalStore( - subscribeHash, - () => window.location.hash.slice(1), - () => "", - ); - return SECTIONS.find((s) => s.id === hash)?.id ?? "checklist"; -} - -const STATE: Record = - { - draft: { label: "Draft", tone: "neutral" }, - open: { label: "Open", tone: "success" }, - closed: { label: "Closed", tone: "warning" }, - }; - -export default function AdminEditionJudgingPage() { - const params = useParams<{ hackathonId: string }>(); - const hackathonId = params?.hackathonId ?? ""; - const section = useSection(); - - const overview = trpc.judging.admin.overview.useQuery( - { hackathonId }, - { enabled: !!hackathonId, refetchInterval: OFFICER_POLL_MS }, - ); - - // Switching from far down a long section leaves the new one scrolled out of - // sight on a phone; bring its top back into view. - const contentRef = useRef(null); - const shown = useRef(section); - useEffect(() => { - if (shown.current === section) return; - shown.current = section; - const top = contentRef.current?.getBoundingClientRect().top ?? 0; - if (top < 0) contentRef.current?.scrollIntoView({ block: "start" }); - }, [section]); - - const data = overview.data; - +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AdminEditionJudgingPage from "./AdminEditionJudgingPage"; + +export default async function Page({ + params, +}: { + params: Promise<{ hackathonId: string }>; +}) { + const { hackathonId } = await params; return ( -
- - ← All editions - -
- {data &&

{data.hackathon.name}

} -

- Judging -

- {data ? ( -

- - Judging {STATE[data.config.state].label.toLowerCase()} - - - {plural( - data.counts.entries.devpost + data.counts.entries.portal, - "project", - )} - - - {stationCount(data.counts.rooms.active, data.counts.rooms.tables)} - - {plural(data.counts.judgesSeated, "judge")} seated - {data.counts.checkInFlags.open > 0 && ( - - {plural(data.counts.checkInFlags.open, "open check-in flag")} - - )} - {data.config.emergencyMode && ( - Emergency mode on - )} -

- ) : null} -
- - {overview.isLoading ? ( -

Loading the edition…

- ) : overview.isError || !data ? ( -
-

- {trpcErrorMessage(overview.error, "Could not load this edition.")} -

-
- - - Pick another edition - -
-
- ) : ( -
- - -
- {section === "checklist" && ( - - )} - {section === "projects" && ( - - )} - {section === "challenges" && ( - - )} - {section === "rooms" && ( - - )} - {section === "rubric" && } - {section === "launch" && ( - - )} - {section === "schedule" && ( - - )} - {section === "claims" && } - {section === "results" && ( - - )} - {section === "repo-check" && ( - - )} - {section === "announcements" && ( - - )} - {section === "danger" && ( - - )} -
-
- )} -
+ [ + [ + "judging.admin.overview", + { hackathonId }, + c.judging.admin.overview({ hackathonId }), + ], + [ + "judging.admin.checklist", + { hackathonId }, + c.judging.admin.checklist({ hackathonId }), + ], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index c025fada..84ddf649 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -1,96 +1,14 @@ -"use client"; - -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; -import { trpc } from "@/lib/trpc"; -import { hackathonStatus } from "@/lib/hackathon-status"; -import { - body, - itemTitle, - meta, - pageDek, - sectionRule, - status, - textLink, -} from "@/components/portal/ui"; - -// Judging is run per edition. AdminLayout gates this page to staff. -export default function AdminJudgingPage() { - const { data: hackathons, isLoading, isError, refetch } = - trpc.hackathon.list.useQuery({ limit: 50 }); +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AdminJudgingPage from "./AdminJudgingPage"; +export default function Page() { return ( -
-
-

- Judging -

-

- Pick an edition to open its judging console: projects, challenges, - rooms, the schedule, launch and results. Each edition's judge - roster is on its hackathon page, under Judges. -

-
- -
- {isLoading ? ( -

Loading editions…

- ) : isError ? ( -

- Could not load the editions.{" "} - -

- ) : !hackathons || hackathons.length === 0 ? ( -

- No editions yet. Create one from{" "} - - Hackathons - {" "} - first. -

- ) : ( -
    - {hackathons.map((h) => { - const conf = hackathonStatus(h.status); - return ( -
  • - -
    -

    {h.name}

    -

    - {conf.label} - {" · "} - {new Date(h.startDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} -

    -
    -
  • - ); - })} -
- )} -
-
+ [ + ["hackathon.list", { limit: 50 }, c.hackathon.list({ limit: 50 })], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx index 64d68639..0a438019 100644 --- a/sites/mainweb/app/(portal)/admin/page.tsx +++ b/sites/mainweb/app/(portal)/admin/page.tsx @@ -1,381 +1,14 @@ -"use client"; - -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { - canViewAdmin, - READ_ONLY_TITLE, - usePortalContext, - useReadOnly, -} from "@/lib/use-portal-context"; -import { useState } from "react"; -import Link from "next/link"; -import { useEventQR } from "@/components/portal/EventQR"; -import { EventFormModal } from "@/components/portal/EventFormModal"; -import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; -import { - body, - btnDanger, - btnPrimary, - btnSecondary, - chip, - itemTitle, - meta, - object, - page, - pageDek, - sectionRule, - status, - textLink, -} from "@/components/portal/ui"; - -const adminTitle = - "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)] text-balance"; - -const inlineLink = - "font-semibold text-[var(--text-primary)] underline decoration-accent underline-offset-4 hover:decoration-[var(--text-primary)]"; - -type Event = { - id: string; - title: string; - description: string | null; - location: string | null; - eventDate: Date; - qrCode: string; - checkInEnabled: boolean; - currentCheckIns: number; - maxCheckIns: number | null; -}; - -export default function AdminPage() { - const { data: session } = useSession(); - const utils = trpc.useUtils(); - - const [showCreateEvent, setShowCreateEvent] = useState(false); - const qr = useEventQR(); - const [editingEvent, setEditingEvent] = useState(null); - const [attendanceEvent, setAttendanceEvent] = useState(null); - const [editError, setEditError] = useState(null); - - const { data: portalContext } = usePortalContext(); - const readOnly = useReadOnly(); - const { data: allEvents, isLoading: eventsLoading } = - trpc.events.listAll.useQuery(undefined, { - enabled: !!session && canViewAdmin(portalContext), - }); - // Bootcamp sessions have their own check-in controls on /admin/bootcamp. - const events = allEvents?.filter((e) => e.kind === "club"); - - type StatusFilter = "all" | "open" | "closed"; - const [statusFilter, setStatusFilter] = useState("all"); - - const createEventMutation = trpc.events.create.useMutation({ - onSuccess: (newEvent) => { - if (newEvent) { - utils.events.listAll.invalidate(); - setShowCreateEvent(false); - void qr.show(newEvent); - } - }, - }); - - const toggleCheckInMutation = trpc.events.toggleCheckIn.useMutation({ - onSuccess: () => utils.events.listAll.invalidate(), - }); - - const deleteEventMutation = trpc.events.delete.useMutation({ - onSuccess: () => { - utils.events.listAll.invalidate(); - qr.hide(); - }, - }); - - // Editing rather than delete-and-recreate: deleting takes every check-in - // already collected with it, and mints a QR nobody's printed sign matches. - const updateEventMutation = trpc.events.update.useMutation({ - onSuccess: () => { - utils.events.listAll.invalidate(); - setEditingEvent(null); - setEditError(null); - }, - onError: (error) => setEditError(error.message), - }); - - type EventForm = { - title: string; - description: string; - location: string; - eventDate: string; - maxCheckIns: string; - }; - - // Empty means no cap — the column is nullable and the door gate only runs - // when a number is set. The form used to hardcode undefined, so capacity was - // unreachable from anywhere in the product. - const parseCapacity = (value: string) => { - const parsed = Number(value); - return value.trim() && Number.isFinite(parsed) && parsed > 0 - ? Math.floor(parsed) - : undefined; - }; - - const handleCreateEvent = (formData: EventForm) => { - createEventMutation.mutate({ - title: formData.title, - description: formData.description || undefined, - location: formData.location || undefined, - eventDate: new Date(formData.eventDate), - maxCheckIns: parseCapacity(formData.maxCheckIns), - }); - }; - - const handleEditEvent = (formData: EventForm) => { - if (!editingEvent) return; - setEditError(null); - updateEventMutation.mutate({ - eventId: editingEvent.id, - title: formData.title, - description: formData.description || null, - location: formData.location || null, - eventDate: new Date(formData.eventDate), - maxCheckIns: parseCapacity(formData.maxCheckIns) ?? null, - }); - }; - - /** `datetime-local` wants local wall-clock, not the UTC ISO string. */ - const toLocalInput = (date: Date) => { - const local = new Date( - date.getTime() - new Date(date).getTimezoneOffset() * 60 * 1000, - ); - return local.toISOString().slice(0, 16); - }; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AdminPage from "./AdminPage"; +export default function Page() { return ( - <> - {showCreateEvent && ( - setShowCreateEvent(false)} - onSubmit={handleCreateEvent} - isSubmitting={createEventMutation.isPending} - /> - )} - - {editingEvent && ( - { - setEditingEvent(null); - setEditError(null); - }} - onSubmit={handleEditEvent} - isSubmitting={updateEventMutation.isPending} - error={editError} - /> - )} - - {attendanceEvent && ( - setAttendanceEvent(null)} - /> - )} - - {qr.modal} - -
-
-

Club check-ins

-

- Club meetings and workshops. Bootcamp sessions are managed on the{" "} - - bootcamp page - - , and Hacklytics weekend events live on the hackathon dashboard. -

-
- -
-
-
- {(["all", "open", "closed"] as const).map((f) => ( - - ))} -
-
- - Scan passes - - -
-
- - {eventsLoading ? ( -
-
- + [ + ["events.listAll", undefined, c.events.listAll()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/club/bootcamp/BootcampPage.tsx b/sites/mainweb/app/(portal)/club/bootcamp/BootcampPage.tsx new file mode 100644 index 00000000..95aaeb54 --- /dev/null +++ b/sites/mainweb/app/(portal)/club/bootcamp/BootcampPage.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { ExternalLink } from "lucide-react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { BootcampAddOn } from "@/components/portal/BootcampAddOn"; +import { BootcampMaterialsTable } from "@/components/portal/BootcampMaterialsTable"; +import { trpc } from "@/lib/trpc"; +import { loginHref } from "@/lib/safe-callback"; +import { + BOOTCAMP_MEETING_TIME, + BOOTCAMP_ROOM, + BOOTCAMP_START_DATE, + BOOTCAMP_WORKSPACE_URL, +} from "@/lib/bootcamp-schedule"; +import { + MEMBERSHIP_CENTS, + SEMESTER_MEMBERSHIP_CENTS, + BOOTCAMP_ADDON_CENTS, + formatCents, +} from "@query/api/pricing"; +import { + body, + btnPrimary, + btnSecondary, + kicker, + label, + meta, + page, + pageDek, + pageTitle, + sectionRule, + sectionTitle, + status, +} from "@/components/portal/ui"; + +/** `2026-fall` is how it is stored; nobody should have to read it that way. */ +function termLabel(term: string) { + const [year, season] = term.split("-"); + if (!year || !season) return term; + return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; +} + +/** Where and when it meets — the thing somebody checks walking to class. */ +function MeetingCard() { + const rows = [ + { label: "Room", value: BOOTCAMP_ROOM }, + { label: "Time", value: BOOTCAMP_MEETING_TIME }, + { label: "First session", value: BOOTCAMP_START_DATE }, + ]; + + return ( +
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.value ?? "To be announced"} +
+
+ ))} +
+ ); +} + +/** Hidden until the URL is set — a button that goes nowhere is worse. */ +function WorkspaceLink() { + if (!BOOTCAMP_WORKSPACE_URL) return null; + + return ( + + Open bootcamp workspace + + ); +} + +/** Members buy the add-on here; non-members go to the flow that sells both. */ +function NotEnrolled({ term }: { term: string }) { + const memberStatus = trpc.member.checkStatus.useQuery(); + const isMember = !!memberStatus.data?.isActive; + + return ( +
+

+ You are not in the {termLabel(term)} bootcamp +

+

+ Nine weeks of Python and data science, taught in person, with the + notebooks to keep. It runs for one semester, so joining covers this term + {isMember + ? "" + : ` — ${formatCents(BOOTCAMP_ADDON_CENTS)} on top of a membership (${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester)`} + . +

+ + {isMember ? ( + + ) : ( + + Become a member + + )} +
+ ); +} + +export default function BootcampPortalPage() { + const { data: session, status: sessionStatus } = useSession(); + const router = useRouter(); + // Queries wait on the session, so a signed-out visitor otherwise sits on + // the loading screen forever. + useEffect(() => { + if (sessionStatus === "unauthenticated") router.push(loginHref()); + }, [sessionStatus, router]); + const progress = trpc.bootcamp.myProgress.useQuery(undefined, { + enabled: !!session, + }); + const workshops = trpc.bootcamp.workshops.useQuery(undefined, { + enabled: !!session, + }); + + if ( + sessionStatus === "loading" || + progress.isPending || + workshops.isPending + ) { + return ; + } + + const data = progress.data; + const enrolled = !!data?.enrolled; + + return ( +
+
+
+

+ {data ? `${termLabel(data.term)} · ` : ""}Data Science at Georgia + Tech +

+

Bootcamp

+ + {enrolled && data && ( + <> +

+ Enrolled + {/* 0 of 0 reads as a failure. */} + {data.held > 0 && ( + + {data.attended} of {data.held} attended + + )} +

+

+ Meeting details, notebooks, datasets, solutions and recordings + for the {termLabel(data.term)} cohort. Solutions and recordings + go up after each in-person workshop. +

+ + )} +
+ + {progress.error && ( +

+ {progress.error.message} +

+ )} + + {workshops.error && ( +

+ {workshops.error.message} +

+ )} + + {/* Enrolled members came for the room and time; everyone else needs the offer. */} + {(enrolled || data) && ( +
+ {enrolled ? : data && } +
+ )} + + {/* Scoped to the member's own cohort by the server, so a past cohort's + files stay here after the term rolls even though the upsell shows. */} + {(enrolled || (workshops.data?.length ?? 0) > 0) && ( +
+

Weekly materials

+
+ +
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx index 95aaeb54..f84ca446 100644 --- a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx +++ b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx @@ -1,213 +1,16 @@ -"use client"; - -import { useEffect } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { ExternalLink } from "lucide-react"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { BootcampAddOn } from "@/components/portal/BootcampAddOn"; -import { BootcampMaterialsTable } from "@/components/portal/BootcampMaterialsTable"; -import { trpc } from "@/lib/trpc"; -import { loginHref } from "@/lib/safe-callback"; -import { - BOOTCAMP_MEETING_TIME, - BOOTCAMP_ROOM, - BOOTCAMP_START_DATE, - BOOTCAMP_WORKSPACE_URL, -} from "@/lib/bootcamp-schedule"; -import { - MEMBERSHIP_CENTS, - SEMESTER_MEMBERSHIP_CENTS, - BOOTCAMP_ADDON_CENTS, - formatCents, -} from "@query/api/pricing"; -import { - body, - btnPrimary, - btnSecondary, - kicker, - label, - meta, - page, - pageDek, - pageTitle, - sectionRule, - sectionTitle, - status, -} from "@/components/portal/ui"; - -/** `2026-fall` is how it is stored; nobody should have to read it that way. */ -function termLabel(term: string) { - const [year, season] = term.split("-"); - if (!year || !season) return term; - return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; -} - -/** Where and when it meets — the thing somebody checks walking to class. */ -function MeetingCard() { - const rows = [ - { label: "Room", value: BOOTCAMP_ROOM }, - { label: "Time", value: BOOTCAMP_MEETING_TIME }, - { label: "First session", value: BOOTCAMP_START_DATE }, - ]; - - return ( -
- {rows.map((row) => ( -
-
{row.label}
-
- {row.value ?? "To be announced"} -
-
- ))} -
- ); -} - -/** Hidden until the URL is set — a button that goes nowhere is worse. */ -function WorkspaceLink() { - if (!BOOTCAMP_WORKSPACE_URL) return null; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import BootcampPage from "./BootcampPage"; +export default function Page() { return ( - [ + ["bootcamp.myProgress", undefined, c.bootcamp.myProgress()], + ["bootcamp.workshops", undefined, c.bootcamp.workshops()], + ["member.checkStatus", undefined, c.member.checkStatus()], + ]} > - Open bootcamp workspace - - ); -} - -/** Members buy the add-on here; non-members go to the flow that sells both. */ -function NotEnrolled({ term }: { term: string }) { - const memberStatus = trpc.member.checkStatus.useQuery(); - const isMember = !!memberStatus.data?.isActive; - - return ( -
-

- You are not in the {termLabel(term)} bootcamp -

-

- Nine weeks of Python and data science, taught in person, with the - notebooks to keep. It runs for one semester, so joining covers this term - {isMember - ? "" - : ` — ${formatCents(BOOTCAMP_ADDON_CENTS)} on top of a membership (${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester)`} - . -

- - {isMember ? ( - - ) : ( - - Become a member - - )} -
- ); -} - -export default function BootcampPortalPage() { - const { data: session, status: sessionStatus } = useSession(); - const router = useRouter(); - // Queries wait on the session, so a signed-out visitor otherwise sits on - // the loading screen forever. - useEffect(() => { - if (sessionStatus === "unauthenticated") router.push(loginHref()); - }, [sessionStatus, router]); - const progress = trpc.bootcamp.myProgress.useQuery(undefined, { - enabled: !!session, - }); - const workshops = trpc.bootcamp.workshops.useQuery(undefined, { - enabled: !!session, - }); - - if ( - sessionStatus === "loading" || - progress.isPending || - workshops.isPending - ) { - return ; - } - - const data = progress.data; - const enrolled = !!data?.enrolled; - - return ( -
-
-
-

- {data ? `${termLabel(data.term)} · ` : ""}Data Science at Georgia - Tech -

-

Bootcamp

- - {enrolled && data && ( - <> -

- Enrolled - {/* 0 of 0 reads as a failure. */} - {data.held > 0 && ( - - {data.attended} of {data.held} attended - - )} -

-

- Meeting details, notebooks, datasets, solutions and recordings - for the {termLabel(data.term)} cohort. Solutions and recordings - go up after each in-person workshop. -

- - )} -
- - {progress.error && ( -

- {progress.error.message} -

- )} - - {workshops.error && ( -

- {workshops.error.message} -

- )} - - {/* Enrolled members came for the room and time; everyone else needs the offer. */} - {(enrolled || data) && ( -
- {enrolled ? : data && } -
- )} - - {/* Scoped to the member's own cohort by the server, so a past cohort's - files stay here after the term rolls even though the upsell shows. */} - {(enrolled || (workshops.data?.length ?? 0) > 0) && ( -
-

Weekly materials

-
- -
-
- )} - -
- -
-
-
+ + ); } diff --git a/sites/mainweb/app/(portal)/club/page.tsx b/sites/mainweb/app/(portal)/club/page.tsx index 5b8f4889..2e129ae5 100644 --- a/sites/mainweb/app/(portal)/club/page.tsx +++ b/sites/mainweb/app/(portal)/club/page.tsx @@ -10,6 +10,7 @@ export default function Page() { ["events.myStats", undefined, c.events.myStats()], ["events.myEvents", undefined, c.events.myEvents()], ["events.list", undefined, c.events.list()], + ["member.myPass", undefined, c.member.myPass()], ]} > diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx index 313d96c2..9b9e7e02 100644 --- a/sites/mainweb/app/(portal)/dashboard/page.tsx +++ b/sites/mainweb/app/(portal)/dashboard/page.tsx @@ -1,12 +1,82 @@ import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import { + isWithinEditionDates, + nextStepQueries, +} from "@/lib/hackathon-next-step"; +import type { Prefetch } from "@/lib/portal-bootstrap.server"; import DashboardPage from "./DashboardPage"; export default function Page() { return ( [ - ["user.me", undefined, c.user.me()], - ]} + reads={async (c) => { + const me = c.user.me(); + const myRegs = c.hackathon.myRegistrations(); + const portal = c.user.getPortalContext(); + const entries: Prefetch[] = [["user.me", undefined, me]]; + + // The page reads up to four more queries for the registration whose + // next step it shows. Work out which, the way the page does, and read + // them now. A guess that misses costs only the prefetch. + const [regs, context] = await Promise.all([ + myRegs.catch(() => null), + portal.catch(() => null), + ]); + if (!regs) return entries; + entries.push(["hackathon.myRegistrations", undefined, myRegs]); + + const now = new Date(); + const eventReg = regs.find( + (r) => + (r.registrationStatus === "approved" || + r.registrationStatus === "checked_in") && + r.hackathon.status !== "cancelled" && + isWithinEditionDates(r.hackathon, now), + ); + const clubView = !eventReg && !!context?.member?.isMember; + const latestReg = + eventReg ?? + regs.find((r) => + r.hackathon.endDate ? new Date(r.hackathon.endDate) >= now : true, + ); + if (clubView || !latestReg) return entries; + + const needs = nextStepQueries({ + now, + hackathon: latestReg.hackathon, + registrationStatus: latestReg.registrationStatus, + }); + const { hackathonId } = latestReg; + if (needs.submission) { + entries.push( + [ + "team.submissionWindow", + { hackathonId }, + c.team.submissionWindow({ hackathonId }), + ], + [ + "team.mySubmission", + { hackathonId }, + c.team.mySubmission({ hackathonId }), + ], + ); + } + if (needs.judging) { + entries.push([ + "judging.hacker.itinerary", + { hackathonId }, + c.judging.hacker.itinerary({ hackathonId }), + ]); + } + if (needs.results) { + entries.push([ + "hackathon.getResults", + { hackathonId }, + c.hackathon.getResults({ hackathonId }), + ]); + } + return entries; + }} > diff --git a/sites/mainweb/app/(portal)/hackathons/HackathonsPage.tsx b/sites/mainweb/app/(portal)/hackathons/HackathonsPage.tsx new file mode 100644 index 00000000..4768dc68 --- /dev/null +++ b/sites/mainweb/app/(portal)/hackathons/HackathonsPage.tsx @@ -0,0 +1,376 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { loginHref } from "@/lib/safe-callback"; +import { useSession } from "next-auth/react"; +import { trpc } from "@/lib/trpc"; +import { hackathonSlug } from "@/lib/hackathon-slug"; +import { useRouter } from "next/navigation"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import Link from "next/link"; +import { StatusBadge } from "@/components/hackathon/StatusBadge"; +import { hackathonStatus } from "@/lib/hackathon-status"; +import { venueFormat, venueParts } from "@/lib/venue-time"; +import { + body, + btnSecondary, + chip, + itemTitle, + kicker, + meta, + object, + page, + pageDek, + pageTitle, + sectionTitle, + status, + tab, + tabList, + textLink, +} from "@/components/portal/ui"; + +type Tab = "browse" | "registrations" | "projects"; + +function formatDate(d: Date | string) { + return venueFormat(d, { month: "short", day: "numeric", year: "numeric" }); +} + +function formatDateRange(start: Date | string, end: Date | string) { + const s = venueParts(start); + const e = venueParts(end); + const short = { month: "short", day: "numeric" } as const; + if (s.month === e.month && s.year === e.year) { + return `${venueFormat(start, short)} – ${e.day}, ${e.year}`; + } + if (s.year === e.year) { + return `${venueFormat(start, short)} – ${venueFormat(end, short)}, ${e.year}`; + } + return `${formatDate(start)} – ${formatDate(end)}`; +} + +export default function HackathonsPage() { + const { data: session, status: authStatus } = useSession(); + const router = useRouter(); + + const [activeTab, setActiveTab] = useState("browse"); + const [statusFilter, setStatusFilter] = useState< + "all" | "open" | "live" | "completed" + >("all"); + + const { data: hackathons, isLoading } = trpc.hackathon.list.useQuery({}); + const { data: myRegs } = trpc.hackathon.myRegistrations.useQuery(undefined, { + enabled: !!session, + }); + + // Covers solo submissions too, which have no team to read them off. + const { data: myProjects } = trpc.team.myProjects.useQuery(undefined, { + enabled: !!session, + }); + const projects = myProjects ?? []; + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.replace("#", "") as Tab; + if (["browse", "registrations", "projects"].includes(hash)) { + setActiveTab(hash); + } + }; + handleHashChange(); + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, []); + + useEffect(() => { + if (authStatus === "unauthenticated") router.push(loginHref()); + }, [authStatus, router]); + + if (authStatus === "loading" || isLoading) + return ; + if (!session) return null; + + const regStatusById = new Map( + myRegs?.map((r) => [r.hackathonId, r.registrationStatus]) ?? [], + ); + + const tabs = [ + { id: "browse", label: "Browse" }, + { id: "registrations", label: "My registrations" }, + { id: "projects", label: "My projects" }, + ]; + + const filters = [ + { key: "all", label: "All" }, + { key: "open", label: "Open" }, + { key: "live", label: "Live" }, + { key: "completed", label: "Past" }, + ] as const; + + const filtered = (hackathons ?? []).filter((h) => { + if (statusFilter === "all") return true; + if (statusFilter === "open") return h.status === "open"; + if (statusFilter === "live") return h.status === "in_progress"; + return h.status === "completed"; + }); + const filterLabel = + filters.find((f) => f.key === statusFilter)?.label.toLowerCase() ?? ""; + + return ( +
+ + ← Dashboard + + +
+

Hackathons

+

+ Events you can register for, your registrations, and projects + you've submitted. +

+
+ + + + {/* Browse */} + {activeTab === "browse" && ( +
+
+ {filters.map(({ key, label }) => ( + + ))} +
+ + {!hackathons || hackathons.length === 0 ? ( +

+ No hackathons are listed yet. New events show up here when + they're announced. +

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

No {filterLabel} hackathons right now.

+ +
+ ) : ( +
+ {filtered.map((h) => { + const conf = hackathonStatus(h.status); + const regStatus = regStatusById.get(h.id); + return ( + +
+ {conf.label} + {regStatus && } +
+ +
+ {h.theme &&

{h.theme}

} +

{h.name}

+ {h.description && ( +

+ {h.description} +

+ )} +
+ +
+

{formatDateRange(h.startDate, h.endDate)}

+ {h.location &&

{h.location}

} + {h.maxParticipants && ( +

+ {h.currentParticipants} of {h.maxParticipants} spots + taken +

+ )} +
+ + View + + ); + })} +
+ )} + +
+
+

Judge a hackathon

+

+ Industry professionals and experienced students score projects + at the expo. You apply per event, and an organiser reviews each + application. +

+
+ + Apply to judge + +
+
+ )} + + {/* My registrations */} + {activeTab === "registrations" && ( +
+ {myRegs && myRegs.length > 0 ? ( +
    + {myRegs.map((reg) => ( +
  • + +
    + +

    + {reg.hackathon.name} +

    + {reg.hackathon.theme && ( +

    + Theme: {reg.hackathon.theme} +

    + )} +

    + {reg.hackathon.description} +

    +
    +
    + + {reg.hackathon.startDate + ? venueFormat(reg.hackathon.startDate) + : "TBA"} + + View +
    + +
  • + ))} +
+ ) : ( +
+

+ You haven't registered for any hackathons yet. +

+ +
+ )} +
+ )} + + {/* My projects */} + {activeTab === "projects" && ( +
+ {projects.length === 0 ? ( +
+

+ Projects you submit to a hackathon show up here. +

+ +
+ ) : ( +
    + {projects.map((project) => ( +
  • + + {project.status === "winner" + ? "Winner" + : project.status === "judging" + ? "In judging" + : "Submitted"} + +

    {project.name}

    +

    + {project.description} +

    + + {(project.githubUrl || project.demoUrl) && ( +
    + {project.githubUrl && ( + + Code + + )} + {project.demoUrl && ( + + Demo + + )} +
    + )} +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/sites/mainweb/app/(portal)/hackathons/page.tsx b/sites/mainweb/app/(portal)/hackathons/page.tsx index 5ba2dc4e..575ddb0f 100644 --- a/sites/mainweb/app/(portal)/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/page.tsx @@ -1,380 +1,16 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { loginHref } from "@/lib/safe-callback"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { hackathonSlug } from "@/lib/hackathon-slug"; -import { useRouter } from "next/navigation"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import Link from "next/link"; -import { StatusBadge } from "@/components/hackathon/StatusBadge"; -import { hackathonStatus } from "@/lib/hackathon-status"; -import { - body, - btnSecondary, - chip, - itemTitle, - kicker, - meta, - object, - page, - pageDek, - pageTitle, - sectionTitle, - status, - tab, - tabList, - textLink, -} from "@/components/portal/ui"; - -type Tab = "browse" | "registrations" | "projects"; - -function formatDate(d: Date | string) { - return new Date(d).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function formatDateRange(start: Date | string, end: Date | string) { - const s = new Date(start); - const e = new Date(end); - if (s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear()) { - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${e.getDate()}, ${e.getFullYear()}`; - } - if (s.getFullYear() === e.getFullYear()) { - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${e.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${e.getFullYear()}`; - } - return `${formatDate(s)} – ${formatDate(e)}`; -} - -export default function HackathonsPage() { - const { data: session, status: authStatus } = useSession(); - const router = useRouter(); - - const [activeTab, setActiveTab] = useState("browse"); - const [statusFilter, setStatusFilter] = useState< - "all" | "open" | "live" | "completed" - >("all"); - - const { data: hackathons, isLoading } = trpc.hackathon.list.useQuery({}); - const { data: myRegs } = trpc.hackathon.myRegistrations.useQuery(undefined, { - enabled: !!session, - }); - - // Covers solo submissions too, which have no team to read them off. - const { data: myProjects } = trpc.team.myProjects.useQuery(undefined, { - enabled: !!session, - }); - const projects = myProjects ?? []; - - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash.replace("#", "") as Tab; - if (["browse", "registrations", "projects"].includes(hash)) { - setActiveTab(hash); - } - }; - handleHashChange(); - window.addEventListener("hashchange", handleHashChange); - return () => window.removeEventListener("hashchange", handleHashChange); - }, []); - - useEffect(() => { - if (authStatus === "unauthenticated") router.push(loginHref()); - }, [authStatus, router]); - - if (authStatus === "loading" || isLoading) - return ; - if (!session) return null; - - const regStatusById = new Map( - myRegs?.map((r) => [r.hackathonId, r.registrationStatus]) ?? [], - ); - - const tabs = [ - { id: "browse", label: "Browse" }, - { id: "registrations", label: "My registrations" }, - { id: "projects", label: "My projects" }, - ]; - - const filters = [ - { key: "all", label: "All" }, - { key: "open", label: "Open" }, - { key: "live", label: "Live" }, - { key: "completed", label: "Past" }, - ] as const; - - const filtered = (hackathons ?? []).filter((h) => { - if (statusFilter === "all") return true; - if (statusFilter === "open") return h.status === "open"; - if (statusFilter === "live") return h.status === "in_progress"; - return h.status === "completed"; - }); - const filterLabel = - filters.find((f) => f.key === statusFilter)?.label.toLowerCase() ?? ""; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import HackathonsPage from "./HackathonsPage"; +export default function Page() { return ( -
- - ← Dashboard - - -
-

Hackathons

-

- Events you can register for, your registrations, and projects - you've submitted. -

-
- - - - {/* Browse */} - {activeTab === "browse" && ( -
-
- {filters.map(({ key, label }) => ( - - ))} -
- - {!hackathons || hackathons.length === 0 ? ( -

- No hackathons are listed yet. New events show up here when - they're announced. -

- ) : filtered.length === 0 ? ( -
-

No {filterLabel} hackathons right now.

- -
- ) : ( -
- {filtered.map((h) => { - const conf = hackathonStatus(h.status); - const regStatus = regStatusById.get(h.id); - return ( - -
- {conf.label} - {regStatus && } -
- -
- {h.theme &&

{h.theme}

} -

{h.name}

- {h.description && ( -

- {h.description} -

- )} -
- -
-

{formatDateRange(h.startDate, h.endDate)}

- {h.location &&

{h.location}

} - {h.maxParticipants && ( -

- {h.currentParticipants} of {h.maxParticipants} spots - taken -

- )} -
- - View - - ); - })} -
- )} - -
-
-

Judge a hackathon

-

- Industry professionals and experienced students score projects - at the expo. You apply per event, and an organiser reviews each - application. -

-
- - Apply to judge - -
-
- )} - - {/* My registrations */} - {activeTab === "registrations" && ( -
- {myRegs && myRegs.length > 0 ? ( -
    - {myRegs.map((reg) => ( -
  • - -
    - -

    - {reg.hackathon.name} -

    - {reg.hackathon.theme && ( -

    - Theme: {reg.hackathon.theme} -

    - )} -

    - {reg.hackathon.description} -

    -
    -
    - - {reg.hackathon.startDate - ? new Date( - reg.hackathon.startDate, - ).toLocaleDateString() - : "TBA"} - - View -
    - -
  • - ))} -
- ) : ( -
-

- You haven't registered for any hackathons yet. -

- -
- )} -
- )} - - {/* My projects */} - {activeTab === "projects" && ( -
- {projects.length === 0 ? ( -
-

- Projects you submit to a hackathon show up here. -

- -
- ) : ( -
    - {projects.map((project) => ( -
  • - - {project.status === "winner" - ? "Winner" - : project.status === "judging" - ? "In judging" - : "Submitted"} - -

    {project.name}

    -

    - {project.description} -

    - - {(project.githubUrl || project.demoUrl) && ( -
    - {project.githubUrl && ( - - Code - - )} - {project.demoUrl && ( - - Demo - - )} -
    - )} -
  • - ))} -
- )} -
- )} -
+ [ + ["hackathon.list", {}, c.hackathon.list({})], + ["hackathon.myRegistrations", undefined, c.hackathon.myRegistrations()], + ["team.myProjects", undefined, c.team.myProjects()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/initiatives/InitiativesPage.tsx b/sites/mainweb/app/(portal)/initiatives/InitiativesPage.tsx new file mode 100644 index 00000000..fc963737 --- /dev/null +++ b/sites/mainweb/app/(portal)/initiatives/InitiativesPage.tsx @@ -0,0 +1,592 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { + ApplicationChip, + InitiativeChip, + initiativeState, + seatLabel, +} from "@/components/portal/initiatives/chips"; +import { + InitiativeFields, + emptyDraft, + toInput, +} from "@/components/portal/initiatives/form-fields"; +import { + body, + btnDanger, + btnPrimary, + btnSecondary, + fieldHint, + fieldLabel, + input, + itemTitle, + meta, + object, + page, + pageDek, + pageTitle, + sectionTitle, +} from "@/components/portal/ui"; +import { trpc } from "@/lib/trpc"; +import { loginHref } from "@/lib/safe-callback"; +import type { RouterOutputs } from "@query/api"; + +type OpenInitiative = RouterOutputs["initiative"]["list"][number]; +type MyApplication = RouterOutputs["initiative"]["myApplications"][number]; +type MyProposal = RouterOutputs["initiative"]["myProposals"][number]; + +const errorText = "text-sm text-[var(--danger)]"; +const quietAction = + "text-sm font-semibold text-[var(--text-subtle)] transition-colors hover:text-[var(--text-primary)] disabled:opacity-50"; +const inlineLink = + "font-semibold text-[var(--text-primary)] underline decoration-accent underline-offset-4 hover:decoration-[var(--text-primary)]"; +const listRow = "border-b border-[var(--border-subtle)] py-5"; + +/** + * Proposing something to run, rather than joining something that exists. + * + * An admin reviews it; approving turns the proposal into a draft project + * and makes the proposer a project leader, so this is the one place a member + * can earn that role. + */ +function ProposeSection({ canPropose }: { canPropose: boolean }) { + const utils = trpc.useUtils(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(emptyDraft); + + const propose = trpc.initiative.propose.useMutation({ + onSuccess: async () => { + setOpen(false); + setDraft(emptyDraft); + await utils.initiative.myProposals.invalidate(); + }, + }); + + if (!canPropose) return null; + + if (!open) { + return ( +
+
+

Have something to build?

+

+ Pitch it. If it is approved you become its project leader and pick + who joins. +

+
+ +
+ ); + } + + return ( +
+
{ + event.preventDefault(); + propose.mutate(toInput(draft)); + }} + > +

Propose a project

+

+ An organiser reviews it. If it is approved you lead it and pick who + joins. +

+ + +
+ + +
+ + {propose.error && ( +

+ {propose.error.message} +

+ )} + +
+ ); +} + +function ProposalRow({ proposal }: { proposal: MyProposal }) { + const utils = trpc.useUtils(); + + const withdraw = trpc.initiative.withdrawProposal.useMutation({ + onSuccess: async () => { + await utils.initiative.myProposals.invalidate(); + }, + }); + + const state = initiativeState(proposal); + + return ( +
  • +
    +
    +
    +

    + {proposal.title} +

    + +
    + {proposal.summary && ( +

    {proposal.summary}

    + )} + + {/* The reviewer's note is the whole point of a decline — without it a + member has no idea what to change before pitching again. */} + {proposal.reviewNote && ( +

    + {proposal.reviewNote} +

    + )} + + {proposal.status === "draft" && ( +

    + Approved. Finish writing it and open it to members from{" "} + + your projects + + . +

    + )} +
    + + {proposal.status === "proposed" && ( + + )} +
    + + {withdraw.error && ( +

    {withdraw.error.message}

    + )} +
  • + ); +} + +function OpenRow({ + initiative, + canApply, +}: { + initiative: OpenInitiative; + canApply: boolean; +}) { + const utils = trpc.useUtils(); + const [writing, setWriting] = useState(false); + const [pitch, setPitch] = useState(""); + + // Both lists move together: applying takes a project out of one and puts + // it into the other, so refreshing one alone renders it twice. + const refresh = async () => { + await Promise.all([ + utils.initiative.list.invalidate(), + utils.initiative.myApplications.invalidate(), + ]); + }; + + const [resume, setResume] = useState<{ + fileName: string; + dataUrl: string; + } | null>(null); + const [resumeError, setResumeError] = useState(null); + + const join = trpc.initiative.requestToJoin.useMutation({ + onSuccess: async () => { + setWriting(false); + setPitch(""); + setResume(null); + await refresh(); + }, + }); + + // 2 MB is the server's whole-payload cap; catching it here saves a round + // trip and says which file was too big. + const readResume = (file: File | undefined) => { + setResumeError(null); + // Cleared first: a rejected pick must not leave the previous file attached + // and submitted under the error. + setResume(null); + if (!file) return; + if (file.type !== "application/pdf") { + return setResumeError( + "Your resume must be a PDF. Export it as one and try again.", + ); + } + if (file.size > 1.4 * 1024 * 1024) { + return setResumeError( + "That PDF is over 1.4 MB. Compress it and try again.", + ); + } + const reader = new FileReader(); + reader.onload = () => + setResume({ fileName: file.name, dataUrl: String(reader.result) }); + reader.onerror = () => + setResumeError("Could not read that file. Try choosing it again."); + reader.readAsDataURL(file); + }; + + return ( +
  • +
    +
    +

    {initiative.title}

    +

    + Led by {initiative.leaderName ?? "a project leader"} ·{" "} + {seatLabel(initiative.accepted, initiative.maxMembers)} + {initiative.commitment ? ` · ${initiative.commitment}` : ""} +

    +
    + + {initiative.isFull ? ( + + Full + + ) : ( + !writing && + canApply && ( + + ) + )} +
    + + {initiative.summary && ( +

    + {initiative.summary} +

    + )} + {initiative.description && ( +

    + {initiative.description} +

    + )} + + {!canApply && !initiative.isFull && ( +

    + Joining needs an active membership.{" "} + + Become a member + +

    + )} + + {writing && ( +
    { + event.preventDefault(); + join.mutate({ + initiativeId: initiative.id, + pitch: pitch.trim(), + resume: resume ?? undefined, + }); + }} + > + +