Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/admin/(protected)/events/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<AdminEventDetailScene eventId={id} />
</HydrationBoundary>
);
}
69 changes: 69 additions & 0 deletions components/ui/table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client';

import * as React from 'react';

import { cn } from '@/lib/utils';

const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-x-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
Table.displayName = 'Table';

const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';

const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';

const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-gray-200 transition-colors hover:bg-gray-100/50 dark:border-gray-800 dark:hover:bg-gray-800/50',
className,
)}
{...props}
/>
),
);
TableRow.displayName = 'TableRow';

const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-2 text-left align-middle font-medium text-gray-500 dark:text-gray-400',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';

const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-2 align-middle', className)} {...props} />
));
TableCell.displayName = 'TableCell';

export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
25 changes: 25 additions & 0 deletions hooks/useAttendeeReport.ts
Original file line number Diff line number Diff line change
@@ -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<AttendeeReport>({
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 };
};
42 changes: 42 additions & 0 deletions scenes/AdminEventDetailScene/AdminEventDetailScene.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<Link
href={routes.admin.dashboard()}
className="mb-6 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-50"
>
<ArrowLeft className="h-4 w-4" />
All events
</Link>
{isError && (
<p className="text-red-700 dark:text-red-300">Failed to load attendees. Try refreshing.</p>
)}
{isLoading && (
<div className="space-y-6">
<div className="h-32 animate-pulse rounded-lg bg-gray-200 dark:bg-gray-800" />
<div className="h-96 animate-pulse rounded-lg bg-gray-200 dark:bg-gray-800" />
</div>
)}
{report && (
<div className="space-y-8">
<EventSummaryHeader report={report} />
<AttendeeTable report={report} />
</div>
)}
</section>
);
};
117 changes: 117 additions & 0 deletions scenes/AdminEventDetailScene/components/AttendeeTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Badge className="bg-rose-200 text-rose-900 dark:bg-rose-900 dark:text-rose-200">
Refunded
</Badge>
);
}
if (attendee.cancelled) return <Badge>Cancelled</Badge>;
return <Badge variant="primary">{attendee.status}</Badge>;
};

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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
Attendees ({report.summary.total})
</h3>
<input
type="search"
value={filter}
onChange={(e) => 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"
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Status</TableHead>
<TableHead>Checked in</TableHead>
<TableHead>Show-up chance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((attendee) => (
<TableRow
key={attendee.id}
className={attendee.probability === null ? 'opacity-60' : undefined}
>
<TableCell className="font-medium text-gray-900 dark:text-gray-50">
{attendee.name ?? '—'}
</TableCell>
<TableCell className="text-gray-500 dark:text-gray-400">
{attendee.email ?? '—'}
</TableCell>
<TableCell className="text-gray-700 dark:text-gray-300">
{attendee.ticketClassName ?? '—'}
</TableCell>
<TableCell>
<StatusBadge attendee={attendee} />
</TableCell>
<TableCell>
{attendee.checkedIn ? (
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
) : (
<span className="text-gray-400 dark:text-gray-500">—</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<ProbabilityBadge probability={attendee.probability} isNew={attendee.isNew} />
{!attendee.isNew && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{attendee.pastCheckins}/{attendee.pastRegistrations} past events
</span>
)}
</div>
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-gray-500 dark:text-gray-400">
{report.summary.total === 0 ? 'No attendees yet.' : 'No attendees match the filter.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
82 changes: 82 additions & 0 deletions scenes/AdminEventDetailScene/components/EventSummaryHeader.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-50">{value}</p>
{hint && <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{hint}</p>}
{children}
</div>
);

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 (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{event.imageUrl && (
<Image
src={event.imageUrl}
alt=""
width={160}
height={80}
className="rounded-lg object-cover"
/>
)}
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{event.name}</h2>
{event.status !== 'live' && <Badge>{event.status}</Badge>}
</div>
<p className="text-gray-500 dark:text-gray-400">
{start.toLocaleString(DateTime.DATETIME_FULL)}
{event.venueName ? ` · ${event.venueName}` : ''}
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatTile label="Registered" value={String(registered)} />
<StatTile label="Checked in" value={String(summary.checkedIn)} />
<StatTile
label="Expected turnout"
value={String(summary.expectedTurnout)}
hint={
summary.eventsConsidered > 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 ? (
<StatTile label="Capacity" value={`${event.sold ?? registered} / ${event.capacity}`}>
<Progress
className="mt-2"
value={Math.round(((event.sold ?? registered) / event.capacity) * 100)}
/>
</StatTile>
) : (
<StatTile label="Capacity" value="—" hint="no ticket classes" />
)}
</div>
</div>
);
};
Loading