From 8340b5ebee6059a3bf186e43177e4e78aec86be8 Mon Sep 17 00:00:00 2001 From: DJCrossman Date: Fri, 21 Aug 2026 11:17:49 -0600 Subject: [PATCH] feat: add admin event detail with attendee show-up predictions Adds /admin/events/[id]: event summary header (Regina-zone date, venue, stat tiles for registered / checked in / expected turnout with the history basis / capacity progress) and the attendee table with name, email, ticket type, status, live checked-in column (60s refetch), and a tiered show-up-chance badge (>=75% green, 50-74% amber, <50% red) with each person's past checkins/registrations; first-timers get a New badge at the community base rate. Cancelled/refunded rows are dimmed with no prediction. Client-side name/email filter. The page prefetches getAttendeeReport server-side under the shared query key and 404s unknown event ids. New hand-rolled table primitives in components/ui/table.tsx follow the existing trimmed shadcn style (the shadcn CLI is unusable here: components.json points at a nonexistent tailwind.config.ts). Co-Authored-By: Claude Fable 5 --- app/admin/(protected)/events/[id]/page.tsx | 33 +++++ components/ui/table.tsx | 69 +++++++++++ hooks/useAttendeeReport.ts | 25 ++++ .../AdminEventDetailScene.tsx | 42 +++++++ .../components/AttendeeTable.tsx | 117 ++++++++++++++++++ .../components/EventSummaryHeader.tsx | 82 ++++++++++++ .../components/ProbabilityBadge.tsx | 35 ++++++ .../AdminEventDetailScene/components/index.ts | 3 + utils/generateQueryKey.ts | 2 +- 9 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 app/admin/(protected)/events/[id]/page.tsx create mode 100644 components/ui/table.tsx create mode 100644 hooks/useAttendeeReport.ts create mode 100644 scenes/AdminEventDetailScene/AdminEventDetailScene.tsx create mode 100644 scenes/AdminEventDetailScene/components/AttendeeTable.tsx create mode 100644 scenes/AdminEventDetailScene/components/EventSummaryHeader.tsx create mode 100644 scenes/AdminEventDetailScene/components/ProbabilityBadge.tsx create mode 100644 scenes/AdminEventDetailScene/components/index.ts diff --git a/app/admin/(protected)/events/[id]/page.tsx b/app/admin/(protected)/events/[id]/page.tsx new file mode 100644 index 0000000..ef29521 --- /dev/null +++ b/app/admin/(protected)/events/[id]/page.tsx @@ -0,0 +1,33 @@ +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { isEventbriteNotFound } from '@/fetch/attendees'; +import { attendeeReportQueryKey } from '@/hooks/useAttendeeReport'; +import { requireAdminOrRedirect } from '@/lib/adminAuth'; +import { getAttendeeReport } from '@/lib/attendeeReport'; +import { AdminEventDetailScene } from '@/scenes/AdminEventDetailScene/AdminEventDetailScene'; + +export const metadata: Metadata = { title: 'Event Attendees - HackRegina' }; + +interface AdminEventPageProps { + params: Promise<{ id: string }>; +} + +export default async function AdminEventPage({ params }: AdminEventPageProps) { + await requireAdminOrRedirect(); + const { id } = await params; + const queryClient = new QueryClient(); + try { + const report = await getAttendeeReport(id); + queryClient.setQueryData(attendeeReportQueryKey(id), report); + } catch (error) { + if (isEventbriteNotFound(error)) notFound(); + // Non-404 failures fall through so the client hook can retry and show its error state. + console.error('Failed to prefetch attendee report:', error); + } + return ( + + + + ); +} diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..37265f0 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,69 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = 'TableCell'; + +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; diff --git a/hooks/useAttendeeReport.ts b/hooks/useAttendeeReport.ts new file mode 100644 index 0000000..1e360ab --- /dev/null +++ b/hooks/useAttendeeReport.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import type { AttendeeReport } from '@/lib/attendeeReport'; +import { generateQueryKey } from '@/utils/generateQueryKey'; + +// Shared with the server prefetch in app/admin/(protected)/events/[id]/page.tsx. +export const attendeeReportQueryKey = (eventId: string) => + generateQueryKey({ key: 'admin-attendees', id: eventId }); + +export const useAttendeeReport = (eventId: string) => { + const { + data: report, + isLoading, + isError, + } = useQuery({ + queryKey: attendeeReportQueryKey(eventId), + queryFn: async () => { + const response = await fetch(`/api/admin/events/${eventId}/attendees`); + if (!response.ok) throw new Error(`Failed to load attendees (${response.status})`); + return response.json(); + }, + // Keep the checked-in column live while an event is running. + refetchInterval: 60_000, + }); + return { report, isLoading, isError }; +}; diff --git a/scenes/AdminEventDetailScene/AdminEventDetailScene.tsx b/scenes/AdminEventDetailScene/AdminEventDetailScene.tsx new file mode 100644 index 0000000..92d6d7c --- /dev/null +++ b/scenes/AdminEventDetailScene/AdminEventDetailScene.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { useAttendeeReport } from '@/hooks/useAttendeeReport'; +import { routes } from '@/lib/route'; +import { AttendeeTable, EventSummaryHeader } from './components'; + +interface AdminEventDetailSceneProps { + eventId: string; +} + +export const AdminEventDetailScene = ({ eventId }: AdminEventDetailSceneProps) => { + const { report, isLoading, isError } = useAttendeeReport(eventId); + + return ( +
+ + + All events + + {isError && ( +

Failed to load attendees. Try refreshing.

+ )} + {isLoading && ( +
+
+
+
+ )} + {report && ( +
+ + +
+ )} +
+ ); +}; diff --git a/scenes/AdminEventDetailScene/components/AttendeeTable.tsx b/scenes/AdminEventDetailScene/components/AttendeeTable.tsx new file mode 100644 index 0000000..34e75ba --- /dev/null +++ b/scenes/AdminEventDetailScene/components/AttendeeTable.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { Check } from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { AttendeeReport, IAttendeeWithPrediction } from '@/lib/attendeeReport'; +import { ProbabilityBadge } from './ProbabilityBadge'; + +interface AttendeeTableProps { + report: AttendeeReport; +} + +const StatusBadge = ({ attendee }: { attendee: IAttendeeWithPrediction }) => { + if (attendee.refunded) { + return ( + + Refunded + + ); + } + if (attendee.cancelled) return Cancelled; + return {attendee.status}; +}; + +export const AttendeeTable = ({ report }: AttendeeTableProps) => { + const [filter, setFilter] = useState(''); + const needle = filter.trim().toLowerCase(); + const rows = needle + ? report.attendees.filter( + (attendee) => + attendee.name?.toLowerCase().includes(needle) || + attendee.email?.toLowerCase().includes(needle), + ) + : report.attendees; + + return ( +
+
+

+ Attendees ({report.summary.total}) +

+ setFilter(e.target.value)} + placeholder="Filter by name or email" + className="h-9 w-64 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-50 dark:placeholder:text-gray-500 dark:focus-visible:ring-gray-300" + /> +
+ + + + Name + Email + Ticket + Status + Checked in + Show-up chance + + + + {rows.map((attendee) => ( + + + {attendee.name ?? '—'} + + + {attendee.email ?? '—'} + + + {attendee.ticketClassName ?? '—'} + + + + + + {attendee.checkedIn ? ( + + ) : ( + + )} + + +
+ + {!attendee.isNew && ( + + {attendee.pastCheckins}/{attendee.pastRegistrations} past events + + )} +
+
+
+ ))} + {rows.length === 0 && ( + + + {report.summary.total === 0 ? 'No attendees yet.' : 'No attendees match the filter.'} + + + )} +
+
+
+ ); +}; diff --git a/scenes/AdminEventDetailScene/components/EventSummaryHeader.tsx b/scenes/AdminEventDetailScene/components/EventSummaryHeader.tsx new file mode 100644 index 0000000..8909516 --- /dev/null +++ b/scenes/AdminEventDetailScene/components/EventSummaryHeader.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { DateTime } from 'luxon'; +import Image from 'next/image'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import type { AttendeeReport } from '@/lib/attendeeReport'; + +interface EventSummaryHeaderProps { + report: AttendeeReport; +} + +interface StatTileProps { + label: string; + value: string; + hint?: string; + children?: React.ReactNode; +} + +const StatTile = ({ label, value, hint, children }: StatTileProps) => ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} + {children} +
+); + +export const EventSummaryHeader = ({ report }: EventSummaryHeaderProps) => { + const { event, summary } = report; + const start = DateTime.fromMillis(event.start).setZone('America/Regina'); + const registered = summary.total - summary.cancelled; + + return ( +
+
+ {event.imageUrl && ( + + )} +
+
+

{event.name}

+ {event.status !== 'live' && {event.status}} +
+

+ {start.toLocaleString(DateTime.DATETIME_FULL)} + {event.venueName ? ` · ${event.venueName}` : ''} +

+
+
+
+ + + 0 + ? `based on ${summary.eventsConsidered} past events · base rate ${Math.round(summary.baseRate * 100)}%` + : 'no attendance history yet — showing default rates' + } + /> + {event.capacity !== null && event.capacity > 0 ? ( + + + + ) : ( + + )} +
+
+ ); +}; diff --git a/scenes/AdminEventDetailScene/components/ProbabilityBadge.tsx b/scenes/AdminEventDetailScene/components/ProbabilityBadge.tsx new file mode 100644 index 0000000..c622d62 --- /dev/null +++ b/scenes/AdminEventDetailScene/components/ProbabilityBadge.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; + +interface ProbabilityBadgeProps { + probability: number | null; + isNew: boolean; +} + +const tierClasses = (percent: number): string => { + if (percent >= 75) { + return 'bg-emerald-200 text-emerald-900 dark:bg-emerald-900 dark:text-emerald-200'; + } + if (percent >= 50) { + return 'bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200'; + } + return 'bg-rose-200 text-rose-900 dark:bg-rose-900 dark:text-rose-200'; +}; + +export const ProbabilityBadge = ({ probability, isNew }: ProbabilityBadgeProps) => { + if (probability === null) { + return ; + } + const percent = Math.round(probability * 100); + return ( + + {percent}% + {isNew && ( + + New + + )} + + ); +}; diff --git a/scenes/AdminEventDetailScene/components/index.ts b/scenes/AdminEventDetailScene/components/index.ts new file mode 100644 index 0000000..37003d8 --- /dev/null +++ b/scenes/AdminEventDetailScene/components/index.ts @@ -0,0 +1,3 @@ +export { AttendeeTable } from './AttendeeTable'; +export { EventSummaryHeader } from './EventSummaryHeader'; +export { ProbabilityBadge } from './ProbabilityBadge'; diff --git a/utils/generateQueryKey.ts b/utils/generateQueryKey.ts index e204cbd..44a1ea9 100644 --- a/utils/generateQueryKey.ts +++ b/utils/generateQueryKey.ts @@ -1,4 +1,4 @@ -type KeyType = 'members' | 'organizations' | 'website' | 'events' | 'admin-events'; +type KeyType = 'members' | 'organizations' | 'website' | 'events' | 'admin-events' | 'admin-attendees'; export const generateQueryKey = ({ key,