From f1a522ce89efee073d23f97a144bdc30ca1564ec Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 24 Sep 2026 21:41:17 -0400 Subject: [PATCH 1/3] feat: open event check-in to everyone, not just members events.checkIn no longer requires an active membership: anyone signed in can scan an event QR and have their attendance recorded. A member's check-in still carries their memberId. Bootcamp-only sessions keep their own current-term enrolment check. The only self check-in scanner lived on /club, which sends non-members to /dashboard, so a non-member had no way to scan in even at an event open to them. The scanner, mutation, and result modals move into useEventCheckIn (components/portal/EventCheckIn.tsx), shared by /club and a new /checkin page that takes anyone signed in. /checkin is in the portal nav for everyone and in proxy.ts's private (no-store) prefixes. The admin "Members only" toggle is gone and create/update no longer accept membersOnly (zod strips it from older clients). The members_only column stays: schema changes here are additive only, so it is left unread and can be dropped in a later change. --- docs/packages/api.md | 2 +- .../src/.internal-tests/qr-checkin.test.ts | 58 +++++---- .../api/src/.internal-tests/routers.test.ts | 4 +- packages/api/src/routers/events.ts | 31 +---- packages/db/src/schemas/events.ts | 5 +- sites/mainweb/app/(portal)/admin/page.tsx | 5 - sites/mainweb/app/(portal)/checkin/page.tsx | 92 ++++++++++++++ sites/mainweb/app/(portal)/club/page.tsx | 100 +-------------- sites/mainweb/app/(portal)/dashboard/page.tsx | 2 +- .../components/portal/EventCheckIn.tsx | 118 ++++++++++++++++++ .../components/portal/EventFormModal.tsx | 23 ---- sites/mainweb/lib/portal-nav.test.ts | 2 + sites/mainweb/lib/portal-nav.ts | 2 + sites/mainweb/proxy.ts | 1 + 14 files changed, 258 insertions(+), 187 deletions(-) create mode 100644 sites/mainweb/app/(portal)/checkin/page.tsx create mode 100644 sites/mainweb/components/portal/EventCheckIn.tsx diff --git a/docs/packages/api.md b/docs/packages/api.md index 903c8cc9..ecdbd101 100644 --- a/docs/packages/api.md +++ b/docs/packages/api.md @@ -96,7 +96,7 @@ Batch status updates allow up to **2500** ids. The global array cap in `scrubMar **Members:** `me`, `register`, `update`, `history`, `myPass`, `rotatePass`, `checkStatus`; admin `list`, `getById`, `adminSearch`, `adminHistory`, `adminGrant`, `adminRevoke`. -**Events:** admin CRUD + QR regenerate; public `list`; member `checkIn`, `myEvents`, `myStats`; scanner `manualCheckIn`, `scanMemberPass`, `attendees`, `removeAttendance`. +**Events:** admin CRUD + QR regenerate; public `list`; signed-in `checkIn` (members and non-members; `/checkin` page), `myEvents`, `myStats`; scanner `manualCheckIn`, `scanMemberPass`, `attendees`, `removeAttendance`. **Initiatives:** leaders `listMine`, `getById`, `create`, `update`, `setStatus`, `setArchived`, `decide`; members `list`, apply/withdraw, propose; admin proposal review; super admin `setLeader`. diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 26d2dac5..105e07d9 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -297,7 +297,7 @@ describe("QR check-in", () => { // ------------------------------------------------------------------- describe("Club event door QR", () => { - it("turns a lapsed member away and records nothing", async () => { + it("admits a lapsed member and keeps their member row on the check-in", async () => { mockFindFirst.mockImplementation((table: string) => { if (table === "events") return clubEvent(); if (table === "hackathons") return { id: HACK_A }; @@ -306,15 +306,13 @@ describe("QR check-in", () => { }); const caller = appRouter.createCaller(createMockCtx("lapsed_user")); - const err: any = await caller.events - .checkIn({ qrCode: QR_OLD }) - .catch((e: unknown) => e); - - expect(err.code).toBe("FORBIDDEN"); - expect(err.message).toMatch(/membership is not active/); - // No attendance row, no capacity counter movement. - expect(mockInsert).not.toHaveBeenCalled(); - expect(mockUpdate).not.toHaveBeenCalled(); + await expect( + caller.events.checkIn({ qrCode: QR_OLD }), + ).resolves.toMatchObject({ success: true }); + expect(mockInsert.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({ + userId: "lapsed_user", + memberId: activeMember.id, + }); }); // Sold by the semester while a membership runs a year, so the door asks @@ -1151,10 +1149,11 @@ describe("QR check-in", () => { // ------------------------------------------------------------------- /** - * A kickoff or interest meeting is run to recruit members, so refusing - * everyone who is not one yet left exactly those events with no attendance. + * Check-in is open to everyone signed in. A kickoff or interest meeting is + * run to recruit members, so refusing everyone who is not one yet left + * exactly those events with no attendance. */ - describe("Events open to non-members", () => { + describe("Check-in for non-members", () => { // clearAllMocks keeps implementations, so the write simulations the // concurrency tests above install would otherwise decide these outcomes. beforeEach(() => { @@ -1172,14 +1171,22 @@ describe("QR check-in", () => { return appRouter.createCaller(createMockCtx("guest_user")); }; - it("admits a non-member to an event that is not members only", async () => { + it("admits a non-member", async () => { await expect( - nonMemberAt({ membersOnly: false }).events.checkIn({ qrCode: QR_OLD }), + nonMemberAt({}).events.checkIn({ qrCode: QR_OLD }), + ).resolves.toMatchObject({ success: true }); + }); + + // Events created before the change still carry members_only = true, and + // the door no longer reads it. + it("admits a non-member to an event still flagged members only", async () => { + await expect( + nonMemberAt({ membersOnly: true }).events.checkIn({ qrCode: QR_OLD }), ).resolves.toMatchObject({ success: true }); }); it("records the check-in with no member row attached", async () => { - await nonMemberAt({ membersOnly: false }).events.checkIn({ + await nonMemberAt({}).events.checkIn({ qrCode: QR_OLD, }); @@ -1191,21 +1198,12 @@ describe("QR check-in", () => { }); }); - it("still turns a non-member away from a members-only event", async () => { - await expect( - nonMemberAt({ membersOnly: true }).events.checkIn({ qrCode: QR_OLD }), - ).rejects.toThrow(/Must be a member/i); - }); - - /** - * Fail closed. A row read before the column existed reports `undefined`, - * and an access gate that reads that as "open to everyone" is the wrong - * way round. - */ - it("treats an unknown membersOnly value as members only", async () => { + it("still turns a non-member away from a bootcamp-only session", async () => { await expect( - nonMemberAt({}).events.checkIn({ qrCode: QR_OLD }), - ).rejects.toThrow(/Must be a member/i); + nonMemberAt({ bootcampOnly: true, bootcampWeek: 3 }).events.checkIn({ + qrCode: QR_OLD, + }), + ).rejects.toThrow(/bootcamp members/i); }); }); diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index af18dc21..401eb744 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -1308,7 +1308,7 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(res.eventTitle).toBe("General Meeting"); }); - it("should block non-members from checking into events", async () => { + it("should let non-members check into events", async () => { const ctx = createMockCtx("non_member_user_id"); mockFindFirst.mockImplementation((table) => { @@ -1324,7 +1324,7 @@ describe("Router Integration and Access Control Verification Suite", () => { const caller = appRouter.createCaller(ctx); await expect( caller.events.checkIn({ qrCode: "00000000-0000-4000-8000-000000000099" }), - ).rejects.toThrowError("Must be a member to check in"); + ).resolves.toMatchObject({ success: true }); }); }); diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index eba32d17..ff540984 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -29,7 +29,6 @@ export const eventRouter = createTRPCRouter({ location: z.string().max(200).optional(), eventDate: z.date(), maxCheckIns: z.number().int().positive().optional(), - membersOnly: z.boolean().optional(), /** Marks this event as week N of the bootcamp running this term. */ bootcampWeek: z.number().int().min(1).max(52).optional(), bootcampOnly: z.boolean().optional(), @@ -80,7 +79,6 @@ export const eventRouter = createTRPCRouter({ eventDate: z.date().optional(), /** Null removes the cap. */ maxCheckIns: z.number().int().positive().nullable().optional(), - membersOnly: z.boolean().optional(), /** Null takes the event back out of the bootcamp. */ bootcampWeek: z.number().int().min(1).max(52).nullable().optional(), bootcampOnly: z.boolean().optional(), @@ -363,31 +361,10 @@ export const eventRouter = createTRPCRouter({ }), ]); - // An event marked open to everyone takes attendance from non-members too — - // that is the point of a kickoff. Only an explicit false opens the door: - // anything else, including a row read before the column existed, keeps the - // membership gate. - if (event.membersOnly !== false) { - if (!member) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Must be a member to check in", - }); - } - - // isActive alone still admits a lapsed membership the portal already reports - // as expired. - if ( - !member.isActive || - !member.membershipEndDate || - member.membershipEndDate <= new Date() - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Your membership is not active", - }); - } - } + // Check-in is open to everyone signed in, member or not: attendance is + // recorded for whoever is in the room. The member row is still looked up + // so a member's check-in carries their memberId. events.membersOnly is no + // longer read. // Bought per semester, so last term's seat is not this term's. Officers can // still check somebody in by hand. diff --git a/packages/db/src/schemas/events.ts b/packages/db/src/schemas/events.ts index a3641487..d8faf932 100644 --- a/packages/db/src/schemas/events.ts +++ b/packages/db/src/schemas/events.ts @@ -26,9 +26,8 @@ export const events = pgTable( eventDate: timestamp("event_date").notNull(), qrCode: text("qr_code").notNull().unique(), checkInEnabled: boolean("check_in_enabled").notNull().default(true), - // A kickoff or interest meeting is run to recruit members, so refusing - // everyone who is not one yet leaves exactly those events with no recordable - // attendance. Defaults true so existing events keep their behaviour. + // No longer read: check-in is open to everyone signed in. Kept because + // schema changes here are additive only; drop it in a later change. membersOnly: boolean("members_only").notNull().default(true), /** Week N of the bootcamp. Null on every event that is not a session. */ bootcampWeek: integer("bootcamp_week"), diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx index a2b53518..c95d14a9 100644 --- a/sites/mainweb/app/(portal)/admin/page.tsx +++ b/sites/mainweb/app/(portal)/admin/page.tsx @@ -21,7 +21,6 @@ type Event = { checkInEnabled: boolean; currentCheckIns: number; maxCheckIns: number | null; - membersOnly: boolean; bootcampWeek: number | null; bootcampOnly: boolean; }; @@ -117,7 +116,6 @@ export default function AdminPage() { location: string; eventDate: string; maxCheckIns: string; - membersOnly: boolean; bootcampWeek: string; bootcampOnly: boolean; }; @@ -147,7 +145,6 @@ export default function AdminPage() { location: formData.location || undefined, eventDate: new Date(formData.eventDate), maxCheckIns: parseCapacity(formData.maxCheckIns), - membersOnly: formData.membersOnly, bootcampWeek: parseWeek(formData.bootcampWeek), bootcampOnly: formData.bootcampOnly, }); @@ -163,7 +160,6 @@ export default function AdminPage() { location: formData.location || null, eventDate: new Date(formData.eventDate), maxCheckIns: parseCapacity(formData.maxCheckIns) ?? null, - membersOnly: formData.membersOnly, bootcampWeek: parseWeek(formData.bootcampWeek) ?? null, bootcampOnly: formData.bootcampOnly, }); @@ -205,7 +201,6 @@ export default function AdminPage() { maxCheckIns: editingEvent.maxCheckIns ? String(editingEvent.maxCheckIns) : "", - membersOnly: editingEvent.membersOnly, bootcampWeek: editingEvent.bootcampWeek ? String(editingEvent.bootcampWeek) : "", diff --git a/sites/mainweb/app/(portal)/checkin/page.tsx b/sites/mainweb/app/(portal)/checkin/page.tsx new file mode 100644 index 00000000..653c8690 --- /dev/null +++ b/sites/mainweb/app/(portal)/checkin/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { QrCode, Search } from "lucide-react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { useEventCheckIn } from "@/components/portal/EventCheckIn"; +import { trpc } from "@/lib/trpc"; + +/** + * Event check-in for anyone signed in. The Club Portal has the same scanner, + * but it is a members' page; attendance is not, so this route takes everyone. + */ +export default function CheckInPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const checkIn = useEventCheckIn(); + + const { data: myStats } = trpc.events.myStats.useQuery(undefined, { + enabled: !!session, + }); + + // Straight back here after signing in: the QR is on the wall in front of + // them, and a detour through the dashboard loses it. + useEffect(() => { + if (status === "unauthenticated") { + router.push(`/login?callbackUrl=${encodeURIComponent("/checkin")}`); + } + }, [status, router]); + + if (status === "loading" || !session) { + return ; + } + + return ( +
+ {checkIn.modals} + +
+

+ Events +

+

+ Event Check-In +

+

+ Scan the QR code shown at the event. Open to everyone, no membership + needed. +

+
+ +
+
+
+ +
+
+

+ At an event? +

+

+ Point your camera at the event's QR code to record your + attendance. +

+
+
+ + +
+ +
+

+ Your Check-Ins +

+
+ + {myStats?.totalEvents ?? 0} + + Events +
+
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/club/page.tsx b/sites/mainweb/app/(portal)/club/page.tsx index 15d48f42..08ea3f26 100644 --- a/sites/mainweb/app/(portal)/club/page.tsx +++ b/sites/mainweb/app/(portal)/club/page.tsx @@ -5,9 +5,8 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { QRScannerModal } from "@/components/portal/QRScannerModal"; import { MemberPassCard } from "@/components/portal/MemberPassCard"; -import { ScanResultModal } from "@/components/portal/ScanResultModal"; +import { useEventCheckIn } from "@/components/portal/EventCheckIn"; import { trpc } from "@/lib/trpc"; import { useSession } from "next-auth/react"; import { @@ -26,7 +25,7 @@ type Tab = "general" | "history" | "status"; export default function ClubPage() { const { data: session, status } = useSession(); const router = useRouter(); - const utils = trpc.useUtils(); + const checkIn = useEventCheckIn(); const { data: userData } = trpc.user.me.useQuery(undefined, { enabled: !!session, @@ -68,44 +67,6 @@ export default function ClubPage() { return () => window.removeEventListener("hashchange", handleHashChange); }, []); - const [showScanner, setShowScanner] = useState(false); - const [scanResult, setScanResult] = useState<{ - success: boolean; - message: string; - eventTitle?: string; - } | null>(null); - const [isPaused, setIsPaused] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [scannedCodes, setScannedCodes] = useState>(new Set()); - - const checkInMutation = trpc.events.checkIn.useMutation({ - onSuccess: async (data) => { - setScanResult({ - success: true, - message: "Check-in successful!", - eventTitle: data.eventTitle, - }); - setShowScanner(false); - setIsProcessing(false); - - utils.events.myStats.setData(undefined, (old) => { - if (!old) return { totalEvents: 1 }; - return { totalEvents: old.totalEvents + 1 }; - }); - - utils.events.myEvents.invalidate(); - utils.events.myStats.invalidate(); - }, - onError: (error) => { - setScanResult({ - success: false, - message: error.message || "Check-in failed", - }); - setShowScanner(false); - setIsProcessing(false); - }, - }); - useEffect(() => { if (status === "unauthenticated") { router.push("/login"); @@ -118,37 +79,6 @@ export default function ClubPage() { } }, [status, memberStatus, router]); - // Closing the scanner forgets what it saw, so reopening can scan the same - // code again. - const [scannerWasOpen, setScannerWasOpen] = useState(showScanner); - if (showScanner !== scannerWasOpen) { - setScannerWasOpen(showScanner); - if (!showScanner) setScannedCodes(new Set()); - } - - const handleScan = async (detectedCodes: { rawValue: string }[]) => { - if (isProcessing || !detectedCodes || detectedCodes.length === 0) return; - const scannedData = detectedCodes[0]?.rawValue; - if (!scannedData) return; - if (scannedCodes.has(scannedData)) return; - setScannedCodes((prev) => new Set(prev).add(scannedData)); - setIsPaused(true); - setIsProcessing(true); - try { - await checkInMutation.mutateAsync({ qrCode: scannedData }); - } catch (error) { - console.error("Check-in error:", error); - } finally { - // Re-arm for the next code; otherwise the scanner takes one per load. - setIsProcessing(false); - setIsPaused(false); - } - }; - - const handleError = (error: unknown) => { - console.error("Scanner error:", error); - }; - if (status === "loading" || !memberStatus) { return ; } @@ -172,27 +102,7 @@ export default function ClubPage() {
- {showScanner && ( - { - setShowScanner(false); - setIsPaused(false); - }} - onScan={handleScan} - onError={handleError} - isProcessing={isProcessing} - isPaused={isPaused} - /> - )} - - {scanResult && ( - setScanResult(null)} - /> - )} + {checkIn.modals}
{/* Profile Header */} @@ -313,8 +223,8 @@ export default function ClubPage() {

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

No sessions scheduled for {termLabel(data.term)}.

- A bootcamp session is an ordinary event with a bootcamp week set - on it, so it takes attendance through the same QR and the same - check-in desk. Create one from the{" "} - - Club Hub - - . + Give a workshop a session date above to schedule it. Its QR code + and check-in controls then appear here.

) : members.length === 0 ? ( @@ -618,8 +712,7 @@ export default function AdminBootcampPage() {

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 the event’s attendance list on the Club - Hub. + wrong check-in from the session’s Attendance list above.

)} diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx index c95d14a9..d357846d 100644 --- a/sites/mainweb/app/(portal)/admin/page.tsx +++ b/sites/mainweb/app/(portal)/admin/page.tsx @@ -5,7 +5,7 @@ import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; import { useState } from "react"; import Link from "next/link"; -import { QRCodeModal } from "@/components/portal/QRCodeModal"; +import { useEventQR } from "@/components/portal/EventQR"; import { EventFormModal } from "@/components/portal/EventFormModal"; import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; @@ -21,8 +21,6 @@ type Event = { checkInEnabled: boolean; currentCheckIns: number; maxCheckIns: number | null; - bootcampWeek: number | null; - bootcampOnly: boolean; }; export default function AdminPage() { @@ -30,20 +28,18 @@ export default function AdminPage() { const utils = trpc.useUtils(); const [showCreateEvent, setShowCreateEvent] = useState(false); - const [showQRCode, setShowQRCode] = useState(null); - const [qrCodeDataURL, setQrCodeDataURL] = useState(""); - const [selectedEvent, setSelectedEvent] = useState(null); + const qr = useEventQR(); const [editingEvent, setEditingEvent] = useState(null); const [attendanceEvent, setAttendanceEvent] = useState(null); - // The QR encoder is fetched on demand, so the press has to say it is working. - const [generatingQR, setGeneratingQR] = useState(null); const [editError, setEditError] = useState(null); const { data: portalContext } = usePortalContext(); - const { data: events, isLoading: eventsLoading } = + const { data: allEvents, isLoading: eventsLoading } = trpc.events.listAll.useQuery(undefined, { enabled: !!session && !!portalContext?.isAdmin, }); + // Bootcamp sessions have their own check-in controls on /admin/bootcamp. + const events = allEvents?.filter((e) => e.bootcampWeek == null); type StatusFilter = "all" | "open" | "closed"; const [statusFilter, setStatusFilter] = useState("all"); @@ -53,8 +49,7 @@ export default function AdminPage() { if (newEvent) { utils.events.listAll.invalidate(); setShowCreateEvent(false); - generateQRCode(newEvent.qrCode); - setSelectedEvent(newEvent as unknown as Event); + void qr.show(newEvent); } }, }); @@ -66,8 +61,7 @@ export default function AdminPage() { const deleteEventMutation = trpc.events.delete.useMutation({ onSuccess: () => { utils.events.listAll.invalidate(); - setShowQRCode(null); - setSelectedEvent(null); + qr.hide(); }, }); @@ -82,42 +76,12 @@ export default function AdminPage() { onError: (error) => setEditError(error.message), }); - const regenerateQRMutation = trpc.events.regenerateQR.useMutation({ - onSuccess: (updatedEvent) => { - utils.events.listAll.invalidate(); - generateQRCode(updatedEvent.qrCode); - setSelectedEvent(updatedEvent); - }, - }); - - const generateQRCode = async (qrCode: string) => { - setGeneratingQR(qrCode); - try { - // ~240KB, and only needed once somebody asks to see a QR. Loading it up - // front put it in the bundle of the page admins open most. - const { default: QRCode } = await import("qrcode"); - const url = await QRCode.toDataURL(qrCode, { - width: 400, - margin: 3, - color: { dark: "#000000", light: "#ffffff" }, - }); - setQrCodeDataURL(url); - setShowQRCode(qrCode); - } catch (err) { - console.error("Error generating QR code:", err); - } finally { - setGeneratingQR(null); - } - }; - type EventForm = { title: string; description: string; location: string; eventDate: string; maxCheckIns: string; - bootcampWeek: string; - bootcampOnly: boolean; }; // Empty means no cap — the column is nullable and the door gate only runs @@ -130,14 +94,6 @@ export default function AdminPage() { : undefined; }; - /** Empty means this is not a bootcamp session; the column is nullable. */ - const parseWeek = (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, @@ -145,8 +101,6 @@ export default function AdminPage() { location: formData.location || undefined, eventDate: new Date(formData.eventDate), maxCheckIns: parseCapacity(formData.maxCheckIns), - bootcampWeek: parseWeek(formData.bootcampWeek), - bootcampOnly: formData.bootcampOnly, }); }; @@ -160,8 +114,6 @@ export default function AdminPage() { location: formData.location || null, eventDate: new Date(formData.eventDate), maxCheckIns: parseCapacity(formData.maxCheckIns) ?? null, - bootcampWeek: parseWeek(formData.bootcampWeek) ?? null, - bootcampOnly: formData.bootcampOnly, }); }; @@ -173,13 +125,6 @@ export default function AdminPage() { return local.toISOString().slice(0, 16); }; - const downloadQRCode = () => { - const link = document.createElement("a"); - link.download = `${selectedEvent?.title || "event"}-qr.png`; - link.href = qrCodeDataURL; - link.click(); - }; - return ( <> {showCreateEvent && ( @@ -201,10 +146,6 @@ export default function AdminPage() { maxCheckIns: editingEvent.maxCheckIns ? String(editingEvent.maxCheckIns) : "", - bootcampWeek: editingEvent.bootcampWeek - ? String(editingEvent.bootcampWeek) - : "", - bootcampOnly: editingEvent.bootcampOnly, }} onClose={() => { setEditingEvent(null); @@ -224,18 +165,7 @@ export default function AdminPage() { /> )} - {showQRCode && selectedEvent && ( - setShowQRCode(null)} - onDownload={downloadQRCode} - onRegenerate={() => - regenerateQRMutation.mutate({ eventId: selectedEvent.id }) - } - isRegenerating={regenerateQRMutation.isPending} - /> - )} + {qr.modal}
@@ -248,9 +178,14 @@ export default function AdminPage() { Manager

- Club meetings, workshops, and bootcamp sessions. These are not - part of a Hacklytics weekend — that itinerary lives on the - hackathon dashboard. + Club meetings and workshops. Bootcamp sessions are managed on the{" "} + + Bootcamp + {" "} + page. Hacklytics weekend events live on the hackathon dashboard.

@@ -378,14 +313,11 @@ export default function AdminPage() {
diff --git a/sites/mainweb/components/portal/EventAttendanceModal.tsx b/sites/mainweb/components/portal/EventAttendanceModal.tsx index c40457fb..d03d2303 100644 --- a/sites/mainweb/components/portal/EventAttendanceModal.tsx +++ b/sites/mainweb/components/portal/EventAttendanceModal.tsx @@ -31,6 +31,8 @@ export function EventAttendanceModal({ await Promise.all([ utils.events.attendees.invalidate({ eventId }), utils.events.listAll.invalidate(), + // Opened from the bootcamp page too, whose grid counts these rows. + utils.bootcamp.attendance.invalidate(), ]); }; diff --git a/sites/mainweb/components/portal/EventFormModal.tsx b/sites/mainweb/components/portal/EventFormModal.tsx index 9fbf0ad7..1caf536a 100644 --- a/sites/mainweb/components/portal/EventFormModal.tsx +++ b/sites/mainweb/components/portal/EventFormModal.tsx @@ -11,10 +11,6 @@ interface EventFormData { /** Empty means no cap. The column is nullable and the door gate only runs * when a number is set. */ maxCheckIns: string; - /** Empty means this is an ordinary event, not a bootcamp session. */ - bootcampWeek: string; - /** Refuses anyone not enrolled in this semester's bootcamp. */ - bootcampOnly: boolean; } interface EventFormModalProps { @@ -50,8 +46,6 @@ export function EventFormModal({ // own date, and overwriting it with "now" would silently reschedule it. eventDate: initial?.eventDate || getCurrentDateTimeLocal(), maxCheckIns: initial?.maxCheckIns ?? "", - bootcampWeek: initial?.bootcampWeek ?? "", - bootcampOnly: initial?.bootcampOnly ?? false, }); const handleSubmit = () => { @@ -178,60 +172,6 @@ export function EventFormModal({

- {/* A session is an ordinary event with a week on it, so it uses this - same QR and check-in desk — no second form, no second scanner. */} -
- - { - const value = e.target.value; - setForm((prev) => ({ - ...prev, - bootcampWeek: value, - // Ticking the box is the step people forget. - bootcampOnly: - !prev.bootcampWeek.trim() && value.trim() - ? true - : prev.bootcampOnly, - })); - }} - className="w-full bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none px-4 py-3 text-[var(--text-primary)] text-sm focus:border-accent focus:outline-none transition-ui font-mono" - placeholder="Leave empty for a normal event" - /> -

- Files this event as week N of the bootcamp running this semester, - and puts its attendance on the bootcamp grid. -

- - -

- Turns away anyone who has not paid for this semester’s - bootcamp. Officers can still check somebody in by hand. -

-
- {error && (

(null); + const [qrCodeDataURL, setQrCodeDataURL] = useState(""); + // The QR encoder is fetched on demand, so the press has to say it is working. + const [generatingFor, setGeneratingFor] = useState(null); + + const show = async (event: QREvent) => { + setGeneratingFor(event.qrCode); + try { + // ~240KB, and only needed once somebody asks to see a QR. Loading it up + // front put it in the bundle of the page admins open most. + const { default: QRCode } = await import("qrcode"); + const url = await QRCode.toDataURL(event.qrCode, { + width: 400, + margin: 3, + color: { dark: "#000000", light: "#ffffff" }, + }); + setQrCodeDataURL(url); + setShownEvent(event); + } catch (err) { + console.error("Error generating QR code:", err); + } finally { + setGeneratingFor(null); + } + }; + + const regenerateQRMutation = trpc.events.regenerateQR.useMutation({ + onSuccess: (updatedEvent) => { + utils.events.listAll.invalidate(); + utils.bootcamp.attendance.invalidate(); + void show(updatedEvent); + }, + }); + + const download = () => { + const link = document.createElement("a"); + link.download = `${shownEvent?.title || "event"}-qr.png`; + link.href = qrCodeDataURL; + link.click(); + }; + + const modal = shownEvent ? ( + setShownEvent(null)} + onDownload={download} + onRegenerate={() => + regenerateQRMutation.mutate({ eventId: shownEvent.id }) + } + isRegenerating={regenerateQRMutation.isPending} + /> + ) : null; + + return { + show, + /** The qrCode currently being encoded, for a button's busy state. */ + generatingFor, + /** Close the QR, e.g. when its event is deleted. */ + hide: () => setShownEvent(null), + modal, + }; +}