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
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Instrument_Serif,
Red_Hat_Display,
} from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthStateSync } from "@/components/auth-state-sync";
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function RootLayout({
<AuthStateSync />
{children}
</TooltipProvider>
<Toaster />
</body>
</html>
);
Expand Down
94 changes: 94 additions & 0 deletions app/reserve/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Image from "next/image";
import Link from "next/link";
import {
getEvents,
getSignedInUser,
getTablesForEvent,
getTeams,
} from "@/lib/db/queries/reservation";
import { ReservationBoard } from "@/components/reservation/reservation-board";

export const dynamic = "force-dynamic";

export default async function ReservePage({
searchParams,
}: {
searchParams: Promise<{ event?: string }>;
}) {
const [events, user, teams, { event: eventParam }] = await Promise.all([
getEvents(),
getSignedInUser(),
getTeams(),
searchParams,
]);

const selectedEvent =
events.find((e) => e.id === eventParam) ?? events[0] ?? null;

const tables = selectedEvent ? await getTablesForEvent(selectedEvent.id) : [];

return (
<div className="min-h-screen bg-white">
<header className="border-b border-zinc-100 px-6 py-4 sm:px-10">
<div className="mx-auto flex max-w-6xl items-center justify-between">
<Link href="/" className="flex items-center gap-3">
<Image
src="/mhacks_logo.png"
alt="MHacks"
width={28}
height={28}
className="opacity-80"
/>
<span className="text-sm font-semibold text-zinc-500">
MHacks 2026
</span>
</Link>
<Link
href="/"
className="text-sm text-zinc-400 transition-colors hover:text-zinc-700"
>
Back to home
</Link>
</div>
</header>

<main className="mx-auto max-w-6xl px-6 py-10 sm:px-10 sm:py-14">
<div className="mb-8">
<h1
className="font-heading text-4xl italic leading-tight tracking-tight sm:text-5xl"
style={{ color: "#3A4A26" }}
>
Reserve a Table
</h1>
<p className="mt-3 max-w-xl text-[14px] leading-7 text-zinc-500">
Claim a spot in the judging area for your team. Select a table on
the map, or let us assign one at random. Reservations are final.
</p>
</div>

{events.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50/60 px-6 py-16 text-center">
<p className="font-heading text-2xl italic text-zinc-500">
No events yet
</p>
<p className="mt-2 text-sm text-zinc-400">
Seed some events with{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[12px]">
pnpm db:seed
</code>{" "}
to start reserving tables.
</p>
</div>
) : (
<ReservationBoard
events={events}
user={user}
teams={teams}
tables={tables}
selectedEventId={selectedEvent!.id}
/>
)}
</main>
</div>
);
}
39 changes: 39 additions & 0 deletions components/reservation/event-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useRouter } from "next/navigation";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Event } from "@/lib/db/queries/reservation";

export function EventPicker({
events,
selectedEventId,
}: {
events: Event[];
selectedEventId: string;
}) {
const router = useRouter();

return (
<Select
value={selectedEventId}
onValueChange={(id) => router.push(`/reserve?event=${id}`)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an event" />
</SelectTrigger>
<SelectContent>
{events.map((event) => (
<SelectItem key={event.id} value={event.id}>
{event.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
151 changes: 151 additions & 0 deletions components/reservation/judging-map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"use client";

import { cn } from "@/lib/utils";
import { DEFAULT_COLUMNS, toRows } from "@/lib/reservation/layout";
import type { TableWithTeam } from "@/lib/db/queries/reservation";

export type TableStatus =
| "available"
| "selected"
| "mine"
| "taken"
| "admin-target";

function statusOf(
table: TableWithTeam,
selectedTableId: string | null,
teamId: string | null,
adminMode: boolean,
): TableStatus {
if (table.id === selectedTableId) return "selected";
if (adminMode && teamId && table.reservedByTeamId === teamId) {
return "admin-target";
}
if (table.reservedByTeamId) {
return teamId && table.reservedByTeamId === teamId ? "mine" : "taken";
}
return "available";
}

const seatStyles: Record<TableStatus, string> = {
available:
"border-zinc-300 bg-white text-zinc-600 hover:border-[#445721] hover:bg-[#445721]/5 hover:text-[#3A4A26]",
selected:
"border-[#445721] bg-[#445721] text-white shadow-sm ring-2 ring-[#445721]/30",
mine: "border-[#445721]/50 bg-[#445721]/15 text-[#3A4A26] ring-1 ring-[#445721]/30",
taken: "border-zinc-200 bg-zinc-100 text-zinc-300",
"admin-target":
"border-amber-500/60 bg-amber-50 text-amber-900 ring-1 ring-amber-500/30",
};

export function JudgingMap({
tables,
selectedTableId,
teamId,
onSelect,
disabled = false,
adminMode = false,
}: {
tables: TableWithTeam[];
selectedTableId: string | null;
teamId: string | null;
onSelect: (table: TableWithTeam) => void;
disabled?: boolean;
adminMode?: boolean;
}) {
const rows = toRows(tables, DEFAULT_COLUMNS);

return (
<div className="space-y-5">
<div className="overflow-x-auto rounded-2xl border border-zinc-200 bg-zinc-50/60 p-5 sm:p-8">
<div className="mx-auto w-fit min-w-full">
<div className="mb-6 flex justify-center">
<div className="rounded-md bg-[#3A4A26]/90 px-10 py-1.5 text-center text-[11px] font-semibold uppercase tracking-[0.3em] text-white sm:px-20">
Judging Stage
</div>
</div>

<div className="flex flex-col items-center gap-2">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex justify-center gap-2">
{row.map((table) => {
const status = statusOf(
table,
selectedTableId,
teamId,
adminMode,
);
const interactive =
!disabled &&
(adminMode ||
status === "available" ||
status === "selected");

return (
<button
key={table.id}
type="button"
disabled={!interactive}
onClick={() => onSelect(table)}
title={
table.reservedByTeamName
? `Table ${table.number} — ${table.reservedByTeamName}`
: `Table ${table.number} — available`
}
aria-label={`Table ${table.number}, ${status}`}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-md border text-[11px] font-semibold transition-all sm:size-10 sm:text-xs",
seatStyles[status],
interactive ? "cursor-pointer" : "cursor-not-allowed",
adminMode &&
status === "taken" &&
"text-zinc-500 hover:border-amber-500/60 hover:bg-amber-50",
)}
>
{table.number}
</button>
);
})}
</div>
))}
</div>
</div>
</div>

<Legend adminMode={adminMode} />
</div>
);
}

function Legend({ adminMode }: { adminMode: boolean }) {
const items: { label: string; className: string }[] = [
{ label: "Available", className: "border-zinc-300 bg-white" },
{ label: "Selected", className: "border-[#445721] bg-[#445721]" },
{
label: "Your table",
className: "border-[#445721]/50 bg-[#445721]/15",
},
{ label: "Reserved", className: "border-zinc-200 bg-zinc-100" },
];

if (adminMode) {
items.push({
label: "Selected team",
className: "border-amber-500/60 bg-amber-50",
});
}

return (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-zinc-500">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<span
className={cn("size-4 rounded border", item.className)}
aria-hidden
/>
{item.label}
</div>
))}
</div>
);
}
Loading
Loading