QR Check-In System Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the QR check-in system with cinema-style tickets and admin scanner for staff to verify and check-in guests at venue reception.
Architecture: Two interconnected pieces: (1) cinema-style confirmation ticket for email and web with QR code, (2) admin scanner page accessible from sidebar for staff to scan and check-in individual tickets. Check-in state persisted in new checkIns table with capacity tracking.
Tech Stack: Next.js 16 App Router, Convex (real-time DB), Tailwind CSS v4, @yudiel/react-qr-scanner for camera access, @react-pdf/renderer for PDF generation.
Business Summary
What this does: Delivers two interconnected pieces: (1) A beautiful cinema-style ticket rendered on the confirmation page and sent via email, featuring the classic Art Deco aesthetic with gold/cream colors, showing all booking details and a prominent QR code. (2) An admin scanner accessible from the sidebar that lets staff scan any handheld device camera to verify bookings and check-in individual guests one by one, with timestamp recording and capacity tracking for analytics.
Why it matters: The cinema ticket creates a memorable, premium confirmation experience that reinforces the House of Legends brand. Staff can efficiently verify and check-in guests using any device with a camera—no specialized hardware needed. Per-ticket check-in allows partial arrivals (e.g., a group of 6 where only 3 arrive together). Capacity analytics show real-time check-in rates per show.
Time to implement: 5-8 days | Complexity: Medium
Dependencies: foundation-plan (for schema additions), confirmation-plan (for existing QR component and confirmation page), notifications-crm-plan (for email sending)
File Map
apps/frontend/
├── app/admin/
│ └── checkin/
│ └── page.tsx # CREATE — scanner + ticket detail page
├── components/
│ ├── confirmation/
│ │ ├── cinema-ticket.tsx # CREATE — cinema ticket display
│ │ └── ticket-email.tsx # CREATE — email template
│ └── admin/
│ └── checkin/
│ ├── scanner.tsx # CREATE — camera scanner component
│ └── ticket-detail.tsx # CREATE — post-scan detail view
├── lib/
│ └── pdf/
│ └── cinema-ticket-pdf.tsx # CREATE — PDF generation
└── lib/schemas/
└── checkin.ts # CREATE — Zod schemas
convex/
├── schema.ts # MODIFY — add checkIns table
└── functions/
├── checkIns.ts # CREATE — check-in mutations + queries
└── reservations.ts # MODIFY — add token fieldPhase 1: Schema — checkIns Table
Task 1: Add checkIns Table to Schema
Files:
-
Modify:
convex/schema.ts -
Step 1: Read existing schema
cat convex/schema.ts | head -100- Step 2: Add checkIns table after reservations table
// Add after reservations table definition (~line 80)
checkIns: defineTable({
ticketId: v.string(), // Reservation token (matches reservation.token)
occurrenceId: v.id("showOccurrences"),
checkedInAt: v.number(), // Unix timestamp
checkedInBy: v.optional(v.string()), // Staff user ID from Clerk
})
.index("by_ticket", ["ticketId"])
.index("by_occurrence", ["occurrenceId"]);- Step 3: Add token field to reservations (if not exists)
Check if qrCode or token field exists in reservations. Add:
token: v.optional(v.string()), // Unique token for QR code- Step 4: Commit
git add convex/schema.ts
git commit -m "feat(checkin): add checkIns table to schema"Phase 2: Convex Functions — checkIns CRUD
Task 2: Create checkIns Functions
Files:
-
Create:
convex/functions/checkIns.ts -
Step 1: Create checkIns Convex functions
// convex/functions/checkIns.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated";
import { staffMutation, staffQuery } from "./auth";
// Check in a single ticket
export const checkIn = staffMutation({
args: {
ticketId: v.string(), // Reservation token
},
handler: async (ctx, { ticketId }) => {
// Find reservation by token
const reservation = await ctx.db
.query("reservations")
.withIndex("by_token", (q) => q.eq("token", ticketId))
.first();
if (!reservation) {
throw new Error("RESERVATION_NOT_FOUND");
}
// Check if already checked in
const existing = await ctx.db
.query("checkIns")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.first();
if (existing) {
throw new Error("ALREADY_CHECKED_IN");
}
// Insert check-in record
return await ctx.db.insert("checkIns", {
ticketId,
occurrenceId: reservation.occurrenceId,
checkedInAt: Date.now(),
checkedInBy: (await ctx.auth.getUserIdentity())?.subject,
});
},
});
// Undo a check-in
export const undoCheckIn = staffMutation({
args: {
ticketId: v.string(),
},
handler: async (ctx, { ticketId }) => {
const checkIn = await ctx.db
.query("checkIns")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.first();
if (!checkIn) {
throw new Error("NOT_FOUND");
}
await ctx.db.delete(checkIn._id);
return { success: true };
},
});
// Get check-in status for a reservation token
export const getByToken = staffQuery({
args: {
token: v.string(),
},
handler: async (ctx, { token }) => {
const reservation = await ctx.db
.query("reservations")
.withIndex("by_token", (q) => q.eq("token", token))
.first();
if (!reservation) {
return null;
}
const checkIns = await ctx.db
.query("checkIns")
.withIndex("by_ticket", (q) => q.eq("ticketId", token))
.first();
return {
reservation,
checkedInAt: checkIns?.checkedInAt,
checkedInBy: checkIns?.checkedInBy,
};
},
});
// Get all check-ins for an occurrence (for capacity tracking)
export const listByOccurrence = staffQuery({
args: {
occurrenceId: v.id("showOccurrences"),
},
handler: async (ctx, { occurrenceId }) => {
return await ctx.db
.query("checkIns")
.withIndex("by_occurrence", (q) => q.eq("occurrenceId", occurrenceId))
.collect();
},
});- Step 2: Commit
git add convex/functions/checkIns.ts
git commit -m "feat(checkin): add checkIns CRUD functions"Phase 3: Cinema Ticket Component
Task 3: Create Cinema Ticket Display Component
Files:
-
Create:
apps/frontend/components/confirmation/cinema-ticket.tsx -
Step 1: Create cinema ticket component
// apps/frontend/components/confirmation/cinema-ticket.tsx
"use client";
import { Ticket } from "~/lib/schemas/checkin";
type CinemaTicketProps = {
ticket: Ticket;
showQrCode?: boolean;
};
export function CinemaTicket({ ticket, showQrCode = true }: CinemaTicketProps) {
return (
<div className="max-w-md mx-auto bg-[#F5F0E6] rounded-lg border-2 border-[#C5A059] overflow-hidden shadow-lg">
{/* Header */}
<div className="bg-[#C5A059] px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<span className="text-lg">★</span>
<span className="font-serif text-lg text-[#2C2C2C] tracking-wider">
HOUSE OF LEGENDS
</span>
<span className="text-lg">★</span>
</div>
<div className="h-px bg-[#2C2C2C] mt-2 opacity-30" />
</div>
{/* Content */}
<div className="px-6 py-5 text-[#2C2C2C]">
{/* Show info */}
<div className="mb-4">
<p className="text-xs uppercase tracking-widest text-[#808080] mb-1">
Show
</p>
<p className="font-serif text-xl">{ticket.showName}</p>
</div>
<div className="flex gap-8 mb-4">
<div>
<p className="text-xs uppercase tracking-widest text-[#808080] mb-1">
Date
</p>
<p className="font-sans font-medium">{ticket.showDate}</p>
</div>
<div>
<p className="text-xs uppercase tracking-widest text-[#808080] mb-1">
Time
</p>
<p className="font-sans font-medium">{ticket.showTime}</p>
</div>
</div>
{/* Divider */}
<div className="border-t-2 border-dashed border-[#C5A059] my-4" />
{/* Details */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[#808080]">Type</span>
<span className="font-medium">
{ticket.ticketType === "DINNER_THEATRE"
? "Dinner Theatre"
: "Show Only"}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#808080]">Guests</span>
<span className="font-medium">{ticket.quantity}</span>
</div>
{ticket.packageName && (
<div className="flex justify-between">
<span className="text-[#808080]">Package</span>
<span className="font-medium">{ticket.packageName}</span>
</div>
)}
{ticket.addOns && ticket.addOns.length > 0 && (
<div className="flex justify-between">
<span className="text-[#808080]">Add-ons</span>
<span className="font-medium">{ticket.addOns.join(", ")}</span>
</div>
)}
</div>
{/* Divider */}
<div className="border-t-2 border-dashed border-[#C5A059] my-4" />
{/* QR Code placeholder */}
{showQrCode && (
<div className="flex justify-center mb-4">
<div className="bg-white p-3 rounded-lg">
{/* QR code will be rendered here */}
<div className="w-32 h-32 bg-[#2C2C2C] flex items-center justify-center text-white text-xs">
QR
</div>
</div>
</div>
)}
{/* Total and Confirmation */}
<div className="text-center">
<p className="text-lg font-serif">
{ticket.totalAmount.toLocaleString()} VND
</p>
<p className="text-xs text-[#808080] mt-1">
Confirmation: {ticket.confirmationId}
</p>
</div>
</div>
{/* Footer bar */}
<div className="bg-[#C5A059] h-2" />
</div>
);
}- Step 2: Create Zod schema for ticket
// apps/frontend/lib/schemas/checkin.ts
import { z } from "zod";
export const ticketSchema = z.object({
token: z.string(), // QR code value
confirmationId: z.string(), // Reservation ID
showName: z.string(),
showDate: z.string(),
showTime: z.string(),
ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
quantity: z.number(),
packageName: z.string().optional(),
addOns: z.array(z.string()).optional(),
totalAmount: z.number(),
guestName: z.string(), // Primary guest name
checkedInAt: z.number().optional(),
});
export type Ticket = z.infer<typeof ticketSchema>;- Step 3: Commit
git add apps/frontend/components/confirmation/cinema-ticket.tsx apps/frontend/lib/schemas/checkin.ts
git commit -m "feat(checkin): add cinema ticket component"Phase 4: Admin Scanner Page
Task 4: Create Admin Check-In Page
Files:
-
Create:
apps/frontend/app/admin/checkin/page.tsx -
Step 1: Create admin check-in page
// apps/frontend/app/admin/checkin/page.tsx
"use client";
import { useState } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { Scanner } from "~/components/admin/checkin/scanner";
import { TicketDetail } from "~/components/admin/checkin/ticket-detail";
import { useTranslations } from "next-intl";
export default function CheckInPage() {
const t = useTranslations("admin.checkin");
const [scannedToken, setScannedToken] = useState<string | null>(null);
const [manualEntry, setManualEntry] = useState("");
const checkIn = useMutation(api.checkIns.checkIn);
const undoCheckIn = useMutation(api.checkIns.undoCheckIn);
function handleScan(token: string) {
setScannedToken(token);
}
function handleManualLookup(e: React.FormEvent) {
e.preventDefault();
if (manualEntry.trim()) {
setScannedToken(manualEntry.trim());
setManualEntry("");
}
}
async function handleCheckIn(token: string) {
try {
await checkIn({ ticketId: token });
} catch (error) {
console.error("Check-in failed:", error);
}
}
async function handleUndoCheckIn(token: string) {
try {
await undoCheckIn({ ticketId: token });
} catch (error) {
console.error("Undo failed:", error);
}
}
if (scannedToken) {
return (
<div className="min-h-screen bg-[#1a1a1a] p-4">
<button
onClick={() => setScannedToken(null)}
className="text-[#C5A059] mb-4 flex items-center gap-2"
>
← Scan Another
</button>
<TicketDetail
token={scannedToken}
onCheckIn={handleCheckIn}
onUndoCheckIn={handleUndoCheckIn}
/>
</div>
);
}
return (
<div className="min-h-screen bg-[#1a1a1a] p-4">
<h1 className="text-2xl font-serif text-[#C5A059] mb-6">{t("title")}</h1>
{/* Scanner */}
<div className="mb-8">
<Scanner onScan={handleScan} />
</div>
{/* Manual entry fallback */}
<div className="border-t border-[#4d4d4d] pt-6">
<p className="text-[#808080] text-sm mb-3 text-center">
— OR ENTER CODE MANUALLY —
</p>
<form onSubmit={handleManualLookup} className="flex gap-2">
<input
type="text"
value={manualEntry}
onChange={(e) => setManualEntry(e.target.value)}
placeholder="Enter reservation token"
className="flex-1 bg-[#2E2E2E] border border-[#4d4d4d] rounded px-4 py-2 text-white"
/>
<button
type="submit"
className="bg-[#C5A059] text-[#1a1a1a] px-6 py-2 rounded font-bold"
>
{t("lookup")}
</button>
</form>
</div>
</div>
);
}- Step 2: Create Scanner component
// apps/frontend/components/admin/checkin/scanner.tsx
"use client";
import { useState } from "react";
import { Scanner as QRScanner } from "@yudiel/react-qr-scanner";
type ScannerProps = {
onScan: (token: string) => void;
};
export function Scanner({ onScan }: ScannerProps) {
const [error, setError] = useState<string | null>(null);
function handleScan(result: string) {
try {
// QR contains JSON: { "type": "HOL_BOOKING", "id": "...", "v": 1 }
const data = JSON.parse(result);
if (data.type === "HOL_BOOKING" && data.id) {
onScan(data.id);
} else {
setError("Invalid QR code format");
}
} catch {
// Not JSON, treat as raw token
onScan(result);
}
}
return (
<div className="relative">
<div className="bg-[#2E2E2E] rounded-lg overflow-hidden aspect-video">
<QRScanner
onScan={(results) => {
if (results.length > 0) {
handleScan(results[0].rawValue);
}
}}
styles={{
container: { width: "100%", height: "100%" },
video: { width: "100%", height: "100%", objectFit: "cover" },
}}
/>
</div>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
<p className="text-[#808080] text-sm text-center mt-2">
Point camera at QR code
</p>
</div>
);
}- Step 3: Create TicketDetail component
// apps/frontend/components/admin/checkin/ticket-detail.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { CinemaTicket } from "~/components/confirmation/cinema-ticket";
import { useTranslations } from "next-intl";
type TicketDetailProps = {
token: string;
onCheckIn: (token: string) => void;
onUndoCheckIn: (token: string) => void;
};
export function TicketDetail({
token,
onCheckIn,
onUndoCheckIn,
}: TicketDetailProps) {
const t = useTranslations("admin.checkin");
const data = useQuery(api.checkIns.getByToken, { token });
if (data === undefined) {
return <div className="text-white">Loading...</div>;
}
if (!data) {
return (
<div className="bg-[#2E2E2E] rounded-lg p-6 text-center">
<p className="text-red-500 text-lg mb-2">Reservation not found</p>
<p className="text-[#808080]">This QR code is not recognized.</p>
</div>
);
}
const { reservation, checkedInAt } = data;
const isCheckedIn = !!checkedInAt;
// Build ticket data for cinema ticket display
const ticket = {
token,
confirmationId: reservation._id,
showName: "House of Legends Show", // Would come from occurrence join
showDate: "May 15, 2026",
showTime: "19:30",
ticketType: reservation.ticketType,
quantity: reservation.quantity,
packageName: undefined,
addOns: undefined,
totalAmount: reservation.totalAmount,
guestName: `${reservation.customerFirstName} ${reservation.customerLastName}`,
checkedInAt,
};
return (
<div className="space-y-6">
{/* Cinema Ticket */}
<CinemaTicket ticket={ticket} showQrCode={false} />
{/* Check-in Status */}
<div className="bg-[#2E2E2E] rounded-lg p-4">
{isCheckedIn ? (
<div className="text-center">
<div className="inline-flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded-full mb-3">
✓ Checked In
</div>
<p className="text-[#808080] text-sm">
at {new Date(checkedInAt).toLocaleTimeString()}
</p>
<button
onClick={() => onUndoCheckIn(token)}
className="mt-3 text-[#C5A059] underline text-sm"
>
Undo check-in
</button>
</div>
) : (
<div className="text-center">
<p className="text-white mb-4">Ready to check in</p>
<button
onClick={() => onCheckIn(token)}
className="bg-[#C5A059] text-[#1a1a1a] px-8 py-3 rounded-full font-bold text-lg"
>
Check In
</button>
</div>
)}
</div>
</div>
);
}- Step 4: Commit
git add apps/frontend/app/admin/checkin/page.tsx apps/frontend/components/admin/checkin/scanner.tsx apps/frontend/components/admin/checkin/ticket-detail.tsx
git commit -m "feat(checkin): add admin scanner page and components"Phase 5: Integration — Add to Admin Sidebar
Task 5: Add Check-In to Admin Navigation
Files:
-
Modify:
apps/frontend/app/admin/layout.tsx(or wherever admin sidebar is defined) -
Step 1: Find admin sidebar navigation
grep -r "Check-In\|checkin" apps/frontend/app/admin/ --include="*.tsx" | head -20- Step 2: Add Check-In nav item
Add to sidebar navigation:
{
label: "Check-In",
href: "/admin/checkin",
icon: <QrCode className="w-5 h-5" />,
},- Step 3: Commit
git add apps/frontend/app/admin/layout.tsx
git commit -m "feat(checkin): add check-in to admin sidebar"Phase 6: Add to Confirmation Page
Task 6: Integrate Cinema Ticket into Confirmation Flow
Files:
-
Modify:
apps/frontend/app/[locale]/booking/page.tsx(confirmation step) -
Step 1: Import cinema ticket component
import { CinemaTicket } from "~/components/confirmation/cinema-ticket";- Step 2: Replace existing booking summary with cinema ticket
Replace the booking recap section with <CinemaTicket ticket={ticketData} />.
- Step 3: Commit
git add apps/frontend/app/[locale]/booking/page.tsx
git commit -m "feat(checkin): integrate cinema ticket into confirmation page"Error Handling
// Error codes for checkIn mutation
const CHECKIN_ERRORS = {
RESERVATION_NOT_FOUND:
"This QR code is not recognized. Please check the ticket.",
ALREADY_CHECKED_IN: "This ticket has already been checked in.",
UNAUTHORIZED: "You must be signed in as staff to check in guests.",
NOT_FOUND: "Check-in record not found for undo.",
} as const;Mobile/Responsive Considerations
- Scanner page works on any device with camera (iOS Safari, Android Chrome)
- Cinema ticket is responsive (max-width with mobile-first layout)
- Manual entry fallback for devices without camera
- Touch targets minimum 44x44px for check-in buttons
i18n Requirements
All user-facing strings must use next-intl:
// en.json / vi.json
{
"admin": {
"checkin": {
"title": "Check-In",
"lookup": "Lookup",
"scanHint": "Point camera at QR code"
}
},
"confirmation": {
"cinemaTicket": {
"show": "Show",
"date": "Date",
"time": "Time",
"type": "Type",
"guests": "Guests",
"total": "Total"
}
}
}Environment-Specific Configuration
// Required env vars:
NEXT_PUBLIC_CONVEX_URL= # Convex deployment URLExpanded Test Scenarios
Unit Tests (Vitest)
describe("checkIns.checkIn", () => {
it("throws RESERVATION_NOT_FOUND when token doesn't exist", async () => {
// mock ctx.db to return null for token lookup
// assert Error("RESERVATION_NOT_FOUND")
});
it("throws ALREADY_CHECKED_IN when ticket already checked in", async () => {
// mock ctx.db to return existing checkIn
// assert Error("ALREADY_CHECKED_IN")
});
it("inserts checkIn record on success", async () => {
// mock ctx.db appropriately
// assert ctx.db.insert called with correct data
});
});
describe("CinemaTicket", () => {
it("renders show name and date", () => {
render(<CinemaTicket ticket={mockTicket} />);
expect(screen.getByText("House of Legends Show")).toBeInTheDocument();
});
it("renders QR code placeholder", () => {
render(<CinemaTicket ticket={mockTicket} showQrCode />);
expect(screen.getByText("QR")).toBeInTheDocument();
});
});E2E Tests (Playwright)
test("SCAN-E2E-1.1: Staff can scan valid QR and see ticket details", async ({
page,
}) => {
await page.goto("/admin/checkin");
// Mock camera or use test QR
await page.getByTestId("scanner-viewfinder").click();
// Simulate QR scan
await expect(page.getByTestId("ticket-detail")).toBeVisible();
});
test("SCAN-E2E-1.2: Staff can check in guest", async ({ page }) => {
await page.goto("/admin/checkin?token=test-token");
await page.getByText("Check In").click();
await expect(page.getByText("✓ Checked In")).toBeVisible();
});
test("SCAN-E2E-1.3: Invalid QR shows error", async ({ page }) => {
await page.goto("/admin/checkin?token=invalid");
await expect(page.getByText("Reservation not found")).toBeVisible();
});Cross-Plan Dependencies
Depends on:
foundation-plan— schema additionsconfirmation-plan— existing confirmation page structurenotifications-crm-plan— email sending capability
Required by:
- Staff operations (uses check-in for walk-in management)
Performance Considerations
- QR scanning uses browser camera API (no server load)
- Check-in mutations are fast (single db insert)
- Convex real-time subscriptions auto-update UI
- At 1000 concurrent users: Convex handles subscriptions natively
Out of Scope (YAGNI)
- Seat assignment
- Waitlist management
- Partial group check-in (beyond per-ticket)
- Payment processing
- Refund handling