plans
2026-05-04
2026 05 04 Qr Checkin Plan

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 field

Phase 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 URL

Expanded 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 additions
  • confirmation-plan — existing confirmation page structure
  • notifications-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