plans
2026-05-03
2026 05 03 Confirmation Plan

Confirmation Experience 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 post-payment confirmation page with animated success, booking recap, QR code, Add to Calendar (Google + Apple), Get Directions, and on-the-fly PDF invoice generation.

Tech Stack: Next.js 16 (App Router), qrcode library for QR generation, @react-pdf/renderer for PDF, Tailwind CSS v4, Framer Motion (animated checkmark success state).

Spec Reference: docs/superpowers/specs/09-confirmation-exp.md

Spec Violation (NOTED): Spec uses /{locale}/booking/{occurrenceId}/confirmation with dynamic URL segment. Plan correctly uses /{locale}/booking?success=true&reservationId=... with nuqs useQueryState per SSG-only tech stack constraint.


Business Summary

What this does: Delivers the post-payment confirmation page where guests land after completing their OnePay transfer. The page displays an animated success state, booking recap with QR code for venue check-in, calendar add-on buttons (Google and Apple Calendar), directions to the venue via Google Maps, and an on-the-fly PDF invoice download generated server-side.

Why it matters: The confirmation page is the last touchpoint in the guest booking experience and sets expectations for the live event. A polished, professional confirmation page with working QR codes and calendar integration reduces no-shows and support inquiries about "where is my booking?" The QR code enables efficient walk-in check-in at the venue, and the PDF invoice serves as the primary proof of purchase for guests who request refunds or need expense reports. Guest confidence in the booking directly impacts word-of-mouth and reviews.

Time to implement: 4-6 days | Complexity: Medium

Dependencies: foundation-plan (for reservations table and getById query), booking-flow-plan (for the payment redirect that lands here)


File Map

apps/frontend/
├── lib/
│   └── schemas/
│       └── confirmation.ts     # CREATE — Zod schemas for confirmation
├── app/[locale]/
│   └── booking/
│       └── page.tsx            # MODIFY — confirmation step with animations + actions
├── components/confirmation/
│   ├── qr-code.tsx            # CREATE — QR code for reservation
│   ├── calendar-buttons.tsx   # CREATE — Google + Apple calendar links
│   ├── directions-button.tsx  # CREATE — Google Maps deep link
│   ├── invoice-pdf.tsx        # CREATE — React component for PDF template
│   ├── animated-check.tsx     # CREATE — animated success/failure indicator
│   └── download-invoice-button.tsx # CREATE — PDF download button
└── lib/
    └── generate-invoice-pdf.ts # CREATE — server action PDF generation

Phase 1: Zod Schemas + QR Code Component

Task 1: Create Zod Schemas + QR Code Component

Files:

  • Create: apps/frontend/lib/schemas/confirmation.ts

  • Create: apps/frontend/components/confirmation/qr-code.tsx

  • Step 1: Create Zod schema file

// apps/frontend/lib/schemas/confirmation.ts
import { z } from "zod";
 
/**
 * Schema for confirmation page search params validation.
 * URL: /{locale}/booking?success=true&reservationId={id}&message={optional}
 */
export const confirmationSearchParamsSchema = z.object({
  success: z.enum(["true", "false"]).optional(),
  reservationId: z.string().optional(),
  message: z.string().optional(),
});
 
export type ConfirmationSearchParams = z.infer<
  typeof confirmationSearchParamsSchema
>;
 
/**
 * Schema for invoice data passed to PDF generator.
 * Validated at runtime via safeParse before PDF generation.
 */
export const invoiceLineItemSchema = z.object({
  label: z.string(),
  amount: z.number().int().nonnegative(),
});
 
export const invoiceDataSchema = z.object({
  id: z.string(),
  customerFirstName: z.string(),
  customerLastName: z.string(),
  customerEmail: z.string().email(),
  showName: z.string(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"),
  time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:MM"),
  ticketType: z.string(),
  quantity: z.number().int().positive(),
  subtotal: z.number().int().nonnegative(),
  surcharges: z.array(invoiceLineItemSchema),
  total: z.number().int().nonnegative(),
  paymentMethod: z.string(),
});
 
export type InvoiceData = z.infer<typeof invoiceDataSchema>;
  • Step 2: Create QR code component
// apps/frontend/components/confirmation/qr-code.tsx
"use client";
import { useEffect, useRef } from "react";
 
interface QRCodeProps {
  /** Value to encode in the QR code (reservation token or ID) */
  value: string;
  /** QR code pixel size (default: 200) */
  size?: number;
}
 
export function QRCode({ value, size = 200 }: QRCodeProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
 
  useEffect(() => {
    // Dynamic import to avoid SSR issues and reduce initial bundle
    import("qrcode").then(({ toCanvas }) => {
      if (canvasRef.current) {
        toCanvas(canvasRef.current, value, {
          width: size,
          margin: 2,
          color: {
            dark: "#C5A059", // Gold primary — matches HOL brand
            light: "#1a1a1a", // Background dark
          },
        });
      }
    });
  }, [value, size]);
 
  return (
    <canvas
      ref={canvasRef}
      className="mx-auto"
      data-testid="qr-code-canvas"
    />
  );
}
  • Step 3: Check/install qrcode
npm list qrcode 2>/dev/null || npm install qrcode @types/qrcode
  • Step 4: Commit
git add apps/frontend/lib/schemas/confirmation.ts apps/frontend/components/confirmation/qr-code.tsx
git commit -m "feat(confirmation): add Zod schemas and QR code component"

Phase 2: Calendar + Directions Buttons + Animated Check

Task 2: Create Calendar, Directions, and Animated Check Components

Files:

  • Create: apps/frontend/components/confirmation/calendar-buttons.tsx

  • Create: apps/frontend/components/confirmation/directions-button.tsx

  • Create: apps/frontend/components/confirmation/animated-check.tsx

  • Step 1: Create animated success/failure indicator

[P1 CRITICAL]: Do NOT use emoji characters in UI. Use IconSymbol component instead per project no-emoji rule.

// apps/frontend/components/confirmation/animated-check.tsx
"use client";
import { IconSymbol } from "~/components/ui/iconSymbol";
 
interface AnimatedCheckProps {
  /** Whether the operation succeeded */
  success: boolean;
}
 
/**
 * Animated checkmark/failure indicator.
 * Uses IconSymbol (not emoji) per HOL premium UI rules.
 * Renders a pulsing gold circle with checkmark on success,
 * or a static red circle with X on failure.
 */
export function AnimatedCheck({ success }: AnimatedCheckProps) {
  if (success) {
    return (
      <div
        className="w-20 h-20 md:w-24 md:h-24 rounded-full bg-[#C5A059]/20 mx-auto flex items-center justify-center animate-pulse"
        data-testid="animated-check-success"
      >
        <IconSymbol
          name="checkmark.circle.fill"
          size={48}
          className="text-[#C5A059]"
        />
      </div>
    );
  }
  return (
    <div
      className="w-20 h-20 md:w-24 md:h-24 rounded-full bg-red-900/20 mx-auto flex items-center justify-center"
      data-testid="animated-check-failure"
    >
      <IconSymbol name="xmark.circle.fill" size={48} className="text-red-400" />
    </div>
  );
}
  • Step 2: Create calendar buttons
// apps/frontend/components/confirmation/calendar-buttons.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
 
interface CalendarButtonsProps {
  showName: string;
  /** Date in YYYY-MM-DD format */
  date: string;
  /** Time in HH:MM format */
  time: string;
  venue: string;
}
 
/**
 * Formats date/time for Google Calendar URL.
 * Google uses: YYYYMMDDTHHmmssZ format (UTC) or YYYYMMDDTHHmmss for local
 */
function formatDateForGoogle(dateStr: string, timeStr: string): string {
  const [year, month, day] = dateStr.split("-");
  const [hours, minutes] = timeStr.split(":");
  // Start time in local format (no Z suffix for local time)
  const start = `${year}${month}${day}T${hours}${minutes}00`;
  // End time: +2 hours
  const endDate = new Date(
    Number(year),
    Number(month) - 1,
    Number(day),
    Number(hours) + 2,
    Number(minutes)
  );
  const endYear = String(endDate.getFullYear());
  const endMonth = String(endDate.getMonth() + 1).padStart(2, "0");
  const endDay = String(endDate.getDate()).padStart(2, "0");
  const endHours = String(endDate.getHours()).padStart(2, "0");
  const end = `${endYear}${endMonth}${endDay}T${endHours}${minutes}00`;
  return `${start}/${end}`;
}
 
/**
 * Generates .ics content for Apple Calendar download.
 */
function generateIcsContent(
  showName: string,
  dateStr: string,
  timeStr: string,
  venue: string
): string {
  const [year, month, day] = dateStr.split("-");
  const [hours, minutes] = timeStr.split(":");
  const start = `${year}${month}${day}T${hours}${minutes}00`;
  const endDate = new Date(
    Number(year),
    Number(month) - 1,
    Number(day),
    Number(hours) + 2,
    Number(minutes)
  );
  const endYear = String(endDate.getFullYear());
  const endMonth = String(endDate.getMonth() + 1).padStart(2, "0");
  const endDay = String(endDate.getDate()).padStart(2, "0");
  const endHours = String(endDate.getHours()).padStart(2, "0");
  const end = `${endYear}${endMonth}${endDay}T${endHours}${minutes}00`;
 
  return [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "BEGIN:VEVENT",
    `DTSTART:${start}`,
    `DTEND:${end}`,
    `SUMMARY:${showName}`,
    `DESCRIPTION:House of Legends — ${showName}`,
    `LOCATION:${venue}`,
    "END:VEVENT",
    "END:VCALENDAR",
  ].join("\n");
}
 
/**
 * Calendar buttons for Google Calendar and Apple Calendar.
 * Uses i18n for all user-facing strings.
 */
export function CalendarButtons({
  showName,
  date,
  time,
  venue,
}: CalendarButtonsProps) {
  const t = useTranslations("confirmation.calendar");
 
  const googleUrl = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(showName)}&dates=${formatDateForGoogle(date, time)}&details=${encodeURIComponent(`House of Legends — ${showName}`)}&location=${encodeURIComponent(venue)}`;
 
  const handleAppleCalendar = () => {
    const icsContent = generateIcsContent(showName, date, time, venue);
    const blob = new Blob([icsContent], { type: "text/calendar" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "house-of-legends-event.ics";
    a.click();
    URL.revokeObjectURL(url);
  };
 
  return (
    <div className="flex flex-col sm:flex-row gap-3" data-testid="calendar-buttons">
      <a
        href={googleUrl}
        target="_blank"
        rel="noopener noreferrer"
        className="flex-1 py-2.5 border border-[#333333] rounded-lg text-center text-sm text-[#808080] hover:border-[#C5A059] hover:text-[#C5A059] transition-colors flex items-center justify-center gap-2"
        data-testid="google-calendar-btn"
      >
        <IconSymbol name="calendar" size={16} />
        {t("google")}
      </a>
      <button
        onClick={handleAppleCalendar}
        className="flex-1 py-2.5 border border-[#333333] rounded-lg text-sm text-[#808080] hover:border-[#C5A059] hover:text-[#C5A059] transition-colors flex items-center justify-center gap-2"
        data-testid="apple-calendar-btn"
        type="button"
      >
        <IconSymbol name="calendar.badge.plus" size={16} />
        {t("apple")}
      </button>
    </div>
  );
}
  • Step 3: Create directions button

[P1 Fix]: Venue coordinates MUST use environment variables, not hardcoded values.

// apps/frontend/components/confirmation/directions-button.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
 
/**
 * Venue coordinates from environment variables (P1 Fix).
 * Defaults match House of Legends, Da Nang location.
 */
const VENUE_LAT = Number(process.env.NEXT_PUBLIC_VENUE_LAT ?? "16.0544");
const VENUE_LNG = Number(process.env.NEXT_PUBLIC_VENUE_LNG ?? "108.2022");
 
/**
 * Google Maps directions deep link.
 * Opens Google Maps with turn-by-turn directions to venue.
 */
export function DirectionsButton() {
  const t = useTranslations("confirmation.directions");
 
  const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${VENUE_LAT},${VENUE_LNG}`;
 
  return (
    <a
      href={googleMapsUrl}
      target="_blank"
      rel="noopener noreferrer"
      className="flex items-center justify-center gap-2 py-2.5 border border-[#333333] rounded-lg text-sm text-[#808080] hover:border-[#C5A059] hover:text-[#C5A059] transition-colors"
      data-testid="directions-btn"
    >
      <IconSymbol name="location.fill" size={16} />
      {t("label")}
    </a>
  );
}
  • Step 4: Commit
git add apps/frontend/components/confirmation/calendar-buttons.tsx apps/frontend/components/confirmation/directions-button.tsx apps/frontend/components/confirmation/animated-check.tsx
git commit -m "feat(confirmation): add calendar and directions buttons with animated check"

Phase 3: PDF Invoice Generation

Task 3: Create PDF Invoice Components and Server Action

Files:

  • Create: apps/frontend/components/confirmation/invoice-pdf.tsx

  • Create: apps/frontend/lib/generate-invoice-pdf.ts

  • Create: apps/frontend/components/confirmation/download-invoice-button.tsx

  • Step 1: Install @react-pdf/renderer

npm list @react-pdf/renderer 2>/dev/null || npm install @react-pdf/renderer
  • Step 2: Create invoice PDF component
// apps/frontend/components/confirmation/invoice-pdf.tsx
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
import type { InvoiceData } from "~/lib/schemas/confirmation";
 
/**
 * PDF invoice styles using @react-pdf/renderer.
 * Uses Helvetica (built-in PDF font) for maximum compatibility.
 */
const styles = StyleSheet.create({
  page: { padding: 40, fontFamily: "Helvetica" },
  header: { fontSize: 24, marginBottom: 20, color: "#C5A059" },
  section: { marginBottom: 15 },
  label: { fontSize: 10, color: "#666", marginBottom: 2 },
  value: { fontSize: 12, marginBottom: 8 },
  tableRow: {
    flexDirection: "row",
    borderBottomWidth: 1,
    borderBottomColor: "#eee",
    paddingVertical: 6,
  },
  tableCell: { flex: 1, fontSize: 10 },
  tableCellRight: { flex: 1, fontSize: 10, textAlign: "right" },
  totalRow: {
    flexDirection: "row",
    borderBottomWidth: 1,
    borderBottomColor: "#333",
    paddingVertical: 6,
    marginTop: 4,
  },
  totalLabel: { flex: 1, fontSize: 12, fontWeight: "bold" },
  totalValue: { flex: 1, fontSize: 12, fontWeight: "bold", textAlign: "right" },
  footer: {
    fontSize: 8,
    color: "#999",
    marginTop: 30,
    textAlign: "center",
  },
});
 
interface InvoicePDFProps {
  reservation: InvoiceData;
}
 
/**
 * React-PDF document component for invoice generation.
 * Renders a formatted A4 invoice with booking details,
 * line items, and payment summary.
 */
export function InvoicePDF({ reservation }: InvoicePDFProps) {
  const issueDate = new Date().toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
 
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        {/* Header */}
        <Text style={styles.header}>House of Legends</Text>
        <Text style={styles.label}>Invoice #{reservation.id}</Text>
        <Text style={styles.label}>Date: {issueDate}</Text>
 
        {/* Guest Info */}
        <View style={styles.section}>
          <Text style={styles.label}>Guest</Text>
          <Text style={styles.value}>
            {reservation.customerFirstName} {reservation.customerLastName}
          </Text>
          <Text style={styles.value}>{reservation.customerEmail}</Text>
        </View>
 
        {/* Booking Details */}
        <View style={styles.section}>
          <Text style={styles.label}>Booking Details</Text>
          <Text style={styles.value}>{reservation.showName}</Text>
          <Text style={styles.value}>
            {reservation.date} at {reservation.time}
          </Text>
          <Text style={styles.value}>
            {reservation.ticketType} x {reservation.quantity}
          </Text>
        </View>
 
        {/* Payment Summary */}
        <View style={styles.section}>
          <View style={styles.tableRow}>
            <Text style={styles.tableCell}>Subtotal</Text>
            <Text style={styles.tableCellRight}>
              {reservation.subtotal.toLocaleString()} VND
            </Text>
          </View>
          {reservation.surcharges.map((s, i) => (
            <View key={i} style={styles.tableRow}>
              <Text style={styles.tableCell}>{s.label}</Text>
              <Text style={styles.tableCellRight}>
                {s.amount.toLocaleString()} VND
              </Text>
            </View>
          ))}
          <View style={styles.totalRow}>
            <Text style={styles.totalLabel}>Total Paid</Text>
            <Text style={styles.totalValue}>
              {reservation.total.toLocaleString()} VND
            </Text>
          </View>
        </View>
 
        {/* Footer */}
        <Text style={styles.footer}>
          House of Legends | Lotus Performance Venue, Da Nang, Vietnam |
          contact@houseoflegends.vn
        </Text>
      </Page>
    </Document>
  );
}
  • Step 3: Create server action for PDF download
// apps/frontend/lib/generate-invoice-pdf.ts
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoicePDF } from "~/components/confirmation/invoice-pdf";
import type { InvoiceData } from "~/lib/schemas/confirmation";
 
/**
 * Generates a PDF buffer for an invoice.
 * Server-side only — uses @react-pdf/renderer.
 *
 * @param reservation - Validated invoice data (must pass invoiceDataSchema.safeParse)
 * @returns Uint8Array PDF buffer
 */
export async function generateInvoicePDF(
  reservation: InvoiceData
): Promise<Uint8Array> {
  // renderToBuffer returns Uint8Array — no type assertion needed
  const buffer = await renderToBuffer(<InvoicePDF reservation={reservation} />);
  return buffer;
}
  • Step 4: Create download button
// apps/frontend/components/confirmation/download-invoice-button.tsx
"use client";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
import { invoiceDataSchema } from "~/lib/schemas/confirmation"; // [P0 FIX]: Zod validation
import { consola } from "consola"; // [P1 FIX]: Structured logging, not console.log
 
interface DownloadInvoiceButtonProps {
  reservationId: string;
  invoiceData: {
    id: string;
    customerFirstName: string;
    customerLastName: string;
    customerEmail: string;
    showName: string;
    date: string;
    time: string;
    ticketType: string;
    quantity: number;
    subtotal: number;
    surcharges: { label: string; amount: number }[];
    total: number;
    paymentMethod: string;
  };
}
 
/**
 * Invoice download button with async PDF generation.
 * Uses useTransition for non-blocking UI update during PDF generation.
 * Validates invoice data with Zod before generating (P0 FIX: no `as any`).
 */
export function DownloadInvoiceButton({
  reservationId,
  invoiceData,
}: DownloadInvoiceButtonProps) {
  const t = useTranslations("confirmation.invoice");
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);
 
  const handleDownload = () => {
    setError(null);
    startTransition(async () => {
      try {
        // [P0 FIX]: Zod validation instead of type assertion
        const parsed = invoiceDataSchema.safeParse(invoiceData);
        if (!parsed.success) {
          consola.error("Invalid invoice data", {
            errors: parsed.error.flatten(),
          });
          throw new Error("Invalid invoice data");
        }
 
        const { generateInvoicePDF } = await import("~/lib/generate-invoice-pdf");
        const pdf = await generateInvoicePDF(parsed.data);
        const blob = new Blob([pdf], { type: "application/pdf" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = `HOL-Invoice-${reservationId}.pdf`;
        a.click();
        URL.revokeObjectURL(url);
      } catch (err) {
        const message = err instanceof Error ? err.message : "PDF generation failed";
        setError(message);
        consola.error("PDF download failed", { error: String(err) });
      }
    });
  };
 
  return (
    <>
      <button
        onClick={handleDownload}
        disabled={isPending}
        className="w-full py-2.5 border border-[#333333] rounded-lg text-sm text-[#808080] hover:border-[#C5A059] hover:text-[#C5A059] flex items-center justify-center gap-2 disabled:opacity-50 transition-colors"
        data-testid="download-invoice-btn"
        type="button"
      >
        <IconSymbol name="doc.text" size={16} />
        {isPending ? t("generating") : t("download")}
      </button>
      {error && (
        <p
          className="text-red-400 text-sm mt-2 flex items-center gap-1"
          data-testid="invoice-error"
        >
          <IconSymbol name="exclamationmark.circle.fill" size={14} />
          {error}
        </p>
      )}
    </>
  );
}
  • Step 5: Commit
git add apps/frontend/lib/generate-invoice-pdf.ts apps/frontend/components/confirmation/invoice-pdf.tsx apps/frontend/components/confirmation/download-invoice-button.tsx
git commit -m "feat(confirmation): add on-the-fly PDF invoice generation"

Phase 4: Confirmation Page Assembly

Task 4: Update Confirmation Page

Files:

  • Modify: apps/frontend/app/[locale]/booking/page.tsx (confirmation step)

[P1 CRITICAL]: Server components must use getTranslations from next-intl/server, NOT useTranslations from next-intl. useTranslations is for client components only.

[P1 CRITICAL]: Do NOT use emoji characters in UI — use IconSymbol components instead.

[P0 CRITICAL]: Use nuqs useQueryState for confirmation page URL params, NOT dynamic URL segments. URL pattern: /{locale}/booking?success=true&reservationId={id}. This is an SSG-only project — no dynamic segments like [occurrenceId].

  • Step 1: Read existing confirmation page, then update it

The confirmation page is a server component that fetches reservation data and renders the UI. The action buttons (calendar, directions, download) are client components.

// apps/frontend/app/[locale]/booking/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { api } from "~/convex/_generated/api";
import { QRCode } from "~/components/confirmation/qr-code";
import { CalendarButtons } from "~/components/confirmation/calendar-buttons";
import { DirectionsButton } from "~/components/confirmation/directions-button";
import { DownloadInvoiceButton } from "~/components/confirmation/download-invoice-button";
import { AnimatedCheck } from "~/components/confirmation/animated-check";
import { getTranslations } from "next-intl/server"; // [P1 FIX]: Server component uses getTranslations
import { IconSymbol } from "~/components/ui/iconSymbol";
 
interface ConfirmationPageProps {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
 
/**
 * Confirmation page — server component.
 * Receives searchParams: ?success=true|false&reservationId={id}&message={optional}
 *
 * Uses nuqs-compatible URL params (no dynamic segments — SSG-only project).
 */
export default async function ConfirmationPage({
  searchParams,
}: ConfirmationPageProps) {
  const params = await searchParams;
  const t = await getTranslations("confirmation.page"); // [P1 FIX]: await getTranslations
  const isSuccess = params.success === "true";
  const reservationId = params.reservationId as string | undefined;
 
  // Failure state — payment failed or missing params
  if (!isSuccess || !reservationId) {
    return (
      <div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
        <div className="text-center max-w-md mx-auto px-4">
          <AnimatedCheck success={false} />
          <h1 className="text-2xl font-serif text-[#e6e6e6] mb-2 mt-4">
            {t("failure.title")}
          </h1>
          <p className="text-[#808080] mb-6">
            {params.message ? String(params.message) : t("failure.defaultMessage")}
          </p>
          <a
            href="/"
            className="px-6 py-3 bg-[#C5A059] text-black font-bold rounded-lg inline-flex items-center gap-2 hover:bg-[#DEC89E] transition-colors"
            data-testid="return-home-btn"
          >
            <IconSymbol name="house.fill" size={16} />
            {t("failure.returnHome")}
          </a>
        </div>
      </div>
    );
  }
 
  // Fetch reservation data from Convex — server-side direct call
  // No real-time subscription needed (one-time read after payment)
  const reservation = await api.reservations.getById({ id: reservationId });
  if (!reservation) notFound();
 
  const venueAddress =
    process.env.NEXT_PUBLIC_VENUE_ADDRESS ?? "Lotus Performance Venue, Da Nang, Vietnam";
 
  return (
    <div className="min-h-screen bg-[#1a1a1a] pt-24 px-4">
      <div className="max-w-lg mx-auto text-center">
        {/* Animated checkmark + success message */}
        <div className="mb-8">
          <AnimatedCheck success={true} />
          <h1 className="font-serif text-3xl text-[#C5A059] mt-4">
            {t("success.title")}
          </h1>
          <p className="text-[#808080] mt-2">{t("success.subtitle")}</p>
        </div>
 
        {/* Booking recap card */}
        <div
          className="bg-[#2E2E2E] border border-[#333333] p-6 rounded-lg mb-6 text-left"
          data-testid="booking-recap"
        >
          <h2 className="font-serif text-xl text-[#e6e6e6] mb-4">
            {reservation.showName}
          </h2>
          <div className="space-y-2 text-sm">
            <div className="flex justify-between">
              <span className="text-[#808080]">{t("field.date")}</span>
              <span className="text-[#e6e6e6]">{reservation.date}</span>
            </div>
            <div className="flex justify-between">
              <span className="text-[#808080]">{t("field.time")}</span>
              <span className="text-[#e6e6e6]">{reservation.time}</span>
            </div>
            <div className="flex justify-between">
              <span className="text-[#808080]">{t("field.tickets")}</span>
              <span className="text-[#e6e6e6]">
                {reservation.ticketType} x {reservation.quantity}
              </span>
            </div>
            <div className="flex justify-between border-t border-[#333333] pt-2 mt-2">
              <span className="text-[#808080] font-medium">
                {t("field.totalPaid")}
              </span>
              <span className="text-[#C5A059] font-bold">
                {reservation.total.toLocaleString()} VND
              </span>
            </div>
          </div>
        </div>
 
        {/* QR Code */}
        <div className="mb-6" data-testid="qr-section">
          <p className="text-sm text-[#808080] mb-3">{t("qrHint")}</p>
          <div className="inline-block p-4 bg-white rounded-lg">
            <QRCode value={reservation.token ?? reservation._id} size={160} />
          </div>
        </div>
 
        {/* Action buttons */}
        <div className="space-y-3">
          <CalendarButtons
            showName={reservation.showName}
            date={reservation.date}
            time={reservation.time}
            venue={venueAddress}
          />
          <DirectionsButton />
          {/* [P1 Fix]: Suspense boundary for async download component */}
          <Suspense
            fallback={
              <button
                disabled
                className="w-full py-2.5 border border-[#333333] rounded-lg text-sm text-[#808080] opacity-50 flex items-center justify-center gap-2"
                data-testid="invoice-loading"
              >
                <IconSymbol name="doc.text" size={16} />
                {t("invoice.generating")}
              </button>
            }
          >
            <DownloadInvoiceButton
              reservationId={reservation._id}
              invoiceData={{
                id: reservation._id,
                customerFirstName: reservation.customerFirstName,
                customerLastName: reservation.customerLastName,
                customerEmail: reservation.customerEmail,
                showName: reservation.showName,
                date: reservation.date,
                time: reservation.time,
                ticketType: reservation.ticketType,
                quantity: reservation.quantity,
                subtotal: reservation.subtotal,
                surcharges: reservation.surcharges ?? [],
                total: reservation.total,
                paymentMethod: "OnePay Virtual Account",
              }}
            />
          </Suspense>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Commit
git add apps/frontend/app/[locale]/booking/page.tsx
git commit -m "feat(confirmation): add full confirmation page with QR, calendar, PDF"

Acceptance Criteria

  1. Confirmation page shows animated success state on successful OnePay payment
  2. QR code displayed for walk-in check-in scanning
  3. "Add to Google Calendar" opens Google Calendar with correct event details
  4. "Add to Apple Calendar" downloads .ics file
  5. "Get Directions" opens Google Maps with venue coordinates from env vars
  6. "Download PDF" generates and downloads invoice PDF on-the-fly
  7. On payment failure, error message shown with return home button
  8. All user-facing strings use i18n (not hardcoded)
  9. No emoji characters in UI — all use IconSymbol
  10. URL uses query params (?success=true&reservationId=...), not dynamic segments

Enrichment Sections

1. Zod Schemas

// apps/frontend/lib/schemas/confirmation.ts
import { z } from "zod";
 
/**
 * Schema for confirmation page search params validation.
 * URL: /{locale}/booking?success=true&reservationId={id}&message={optional}
 */
export const confirmationSearchParamsSchema = z.object({
  success: z.enum(["true", "false"]).optional(),
  reservationId: z.string().optional(),
  message: z.string().optional(),
});
 
export type ConfirmationSearchParams = z.infer<
  typeof confirmationSearchParamsSchema
>;
 
/**
 * Schema for invoice line items (surcharges, add-ons).
 */
export const invoiceLineItemSchema = z.object({
  label: z.string(),
  amount: z.number().int().nonnegative(),
});
 
/**
 * Schema for invoice data passed to PDF generator.
 * Validated at runtime via safeParse before PDF generation.
 */
export const invoiceDataSchema = z.object({
  id: z.string(),
  customerFirstName: z.string(),
  customerLastName: z.string(),
  customerEmail: z.string().email(),
  showName: z.string(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"),
  time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:MM"),
  ticketType: z.string(),
  quantity: z.number().int().positive(),
  subtotal: z.number().int().nonnegative(),
  surcharges: z.array(invoiceLineItemSchema),
  total: z.number().int().nonnegative(),
  paymentMethod: z.string(),
});
 
export type InvoiceData = z.infer<typeof invoiceDataSchema>;

2. Error Handling

// Confirmation page errors:
const CONFIRMATION_ERRORS = {
  RESERVATION_NOT_FOUND: "CONF_001",
  PDF_GENERATION_FAILED: "CONF_002",
  INVALID_SEARCH_PARAMS: "CONF_003",
} as const;
type ConfirmationError = keyof typeof CONFIRMATION_ERRORS;
 
// Server component error boundary pattern:
export default async function ConfirmationPage({ searchParams }: ConfirmationPageProps) {
  try {
    const params = await searchParams;
    const isSuccess = params.success === "true";
    // ... rendering logic
  } catch (error) {
    // Log via consola, show generic error
    consola.error("Confirmation page error", { error: String(error) });
    return <ErrorState message="Something went wrong. Please contact support." />;
  }
}
 
// DownloadInvoiceButton client errors:
const handleDownload = () => {
  setError(null);
  startTransition(async () => {
    try {
      const parsed = invoiceDataSchema.safeParse(invoiceData);
      if (!parsed.success) {
        throw new Error("Invalid invoice data");
      }
      const pdf = await generateInvoicePDF(parsed.data);
      // ... download logic
    } catch (err) {
      setError(err instanceof Error ? err.message : "PDF generation failed");
    }
  });
};

3. Convex Real-time Subscription Pattern

The confirmation page is a server component that calls Convex directly (no subscription needed — this is a one-time read after payment):

// Server component — direct API call, no useQuery
const reservation = await api.reservations.getById({ id: reservationId });

If the confirmation page is also used for real-time status updates (e.g., waiting for payment confirmation), use a client component wrapper:

// components/confirmation/reservation-status.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
 
export function ReservationStatus({ reservationId }: { reservationId: string }) {
  // Subscribes to real-time updates — useful if payment status changes
  const reservation = useQuery(api.reservations.getById, { id: reservationId });
  return <p>Status: {reservation?.paymentStatus}</p>;
}

4. Mobile/Responsive Considerations

  • Booking recap: Full-width on mobile, constrained width on desktop (max-w-lg).
  • QR code: Sized at 160px for mobile readability. Can be tapped to enlarge.
  • Calendar buttons: Stack vertically on very small screens (flex-col sm:flex-row).
  • Invoice download: Full-width on mobile, centered on desktop.
  • Animated checkmark: Scales down slightly on mobile (w-20 h-20 instead of w-24 h-24).

5. PWA / Offline Behavior

The confirmation page should be cacheable after initial load. Service worker strategy:

// sw.js — confirmation page caching
registerRoute(
  /\/[a-z]{2}\/booking.*success=true.*/,
  new NetworkFirst({
    cacheName: "confirmation-pages",
    networkTimeoutSeconds: 3,
  }),
);

The QR code and booking details are user-specific — do not cache in a shared cache. Use Cache-Control: private, no-store for the confirmation page.


6. i18n / next-intl Requirements

All user-facing strings must use getTranslations (server) or useTranslations (client). Never hardcoded strings.

[P1 CRITICAL]: Server components must use getTranslations from next-intl/server. Client components use useTranslations from next-intl. Never import useTranslations from next-intl/server.

Required translation keys:

{
  "confirmation": {
    "page": {
      "success": {
        "title": "Booking Confirmed!",
        "subtitle": "Your tickets are ready"
      },
      "failure": {
        "title": "Payment Failed",
        "defaultMessage": "Something went wrong",
        "returnHome": "Return Home"
      },
      "field": {
        "date": "Date",
        "time": "Time",
        "tickets": "Tickets",
        "totalPaid": "Total Paid"
      },
      "qrHint": "Show this at check-in"
    },
    "calendar": {
      "google": "Add to Google Calendar",
      "apple": "Add to Apple Calendar"
    },
    "directions": {
      "label": "Get Directions"
    },
    "invoice": {
      "download": "Download PDF Invoice",
      "generating": "Generating..."
    }
  }
}

Note: The confirmation page URL (/{locale}/booking?success=true&reservationId=...) is determined by the booking flow's redirect after payment. The page is server-rendered and fully localizable.


7. Environment-Specific Configuration

# .env.local
NEXT_PUBLIC_VENUE_LAT=16.0544
NEXT_PUBLIC_VENUE_LNG=108.2022
NEXT_PUBLIC_VENUE_ADDRESS=Lotus Performance Venue, Da Nang, Vietnam
 
# .env.production
NEXT_PUBLIC_VENUE_LAT=16.0544
NEXT_PUBLIC_VENUE_LNG=108.2022
NEXT_PUBLIC_VENUE_ADDRESS=Lotus Performance Venue, Da Nang, Vietnam

8. TDD Test Cases

All tests follow user-expectation format with Given/When/Then structure.

E2E Tests (Playwright)

// e2e/confirmation.spec.ts
import { test, expect } from "@playwright/test";
 
test("CE-E2E-1.1: Successful payment shows confirmation", async ({ page }) => {
  // Given: Guest has completed a payment
  // When: Guest navigates to /en/booking?success=true&reservationId=res-123
  // Then: Booking confirmation page loads with animated checkmark, QR code, and action buttons
  await page.goto(
    "http://localhost:3000/en/booking?success=true&reservationId=res-123",
  );
  await expect(page.getByTestId("animated-check-success")).toBeVisible();
  await expect(page.getByTestId("qr-code-canvas")).toBeVisible();
  await expect(page.getByTestId("calendar-buttons")).toBeVisible();
  await expect(page.getByTestId("directions-btn")).toBeVisible();
});
 
test("CE-E2E-1.2: Invalid reservationId shows not found", async ({ page }) => {
  // Given: Guest has an invalid reservation ID
  // When: Guest navigates to /en/booking?success=true&reservationId=nonexistent
  // Then: 404 not found page is displayed
  await page.goto(
    "http://localhost:3000/en/booking?success=true&reservationId=nonexistent",
  );
  await expect(page).toHaveURL(/not-found/);
});
 
test("CE-E2E-1.3: Payment failure shows error state", async ({ page }) => {
  // Given: Guest payment has failed
  // When: Guest navigates to /en/booking?success=false
  // Then: Error message and return home button are displayed
  await page.goto("http://localhost:3000/en/booking?success=false");
  await expect(page.getByTestId("animated-check-failure")).toBeVisible();
  await expect(page.getByTestId("return-home-btn")).toBeVisible();
});

Unit Tests (Vitest + RTL) — Calendar Buttons

// __tests__/confirmation/calendar-buttons.test.tsx
import { render, screen } from "@testing-library/react";
import { CalendarButtons } from "~/components/confirmation/calendar-buttons";
 
describe("CalendarButtons", () => {
  it("CE-UT01: renders Google and Apple Calendar buttons", () => {
    // Given: CalendarButtons component with show details
    // When: Component renders
    // Then: Both Google and Apple Calendar buttons are visible
    render(
      <CalendarButtons
        showName="Dinner & Show"
        date="2026-05-15"
        time="19:00"
        venue="Lotus Performance Venue"
      />
    );
    expect(screen.getByTestId("google-calendar-btn")).toBeInTheDocument();
    expect(screen.getByTestId("apple-calendar-btn")).toBeInTheDocument();
  });
 
  it("CE-UT02: generates valid Google Calendar URL", () => {
    // Given: CalendarButtons component
    // When: Component renders
    // Then: Google Calendar link contains correct event details
    const { getByTestId } = render(
      <CalendarButtons
        showName="Test Show"
        date="2026-05-15"
        time="19:00"
        venue="Test Venue"
      />
    );
    const googleLink = getByTestId("google-calendar-btn");
    expect(googleLink).toHaveAttribute("href");
    expect(googleLink.href).toContain("calendar.google.com");
    expect(googleLink.href).toContain(encodeURIComponent("Test Show"));
  });
});

Unit Tests (Vitest) — Invoice PDF

// __tests__/confirmation/invoice-pdf.test.ts
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoicePDF } from "~/components/confirmation/invoice-pdf";
 
describe("InvoicePDF", () => {
  it("CE-UT03: generates PDF buffer", async () => {
    // Given: Valid invoice reservation data
    const mockReservation = {
      id: "res-123",
      customerFirstName: "John",
      customerLastName: "Doe",
      customerEmail: "john@example.com",
      showName: "Dinner Theatre",
      date: "2026-05-15",
      time: "19:00",
      ticketType: "DINNER_THEATRE",
      quantity: 2,
      subtotal: 3000000,
      surcharges: [{ label: "Service charge", amount: 300000 }],
      total: 3300000,
      paymentMethod: "OnePay",
    };
    // When: renderToBuffer is called with InvoicePDF
    // Then: A valid Uint8Array PDF buffer is returned
    const buffer = await renderToBuffer(
      <InvoicePDF reservation={mockReservation} />
    );
    expect(buffer).toBeInstanceOf(Uint8Array);
    expect(buffer.length).toBeGreaterThan(0);
  });
});

Unit Tests (Vitest + RTL) — QR Code

// __tests__/confirmation/qr-code.test.tsx
import { render } from "@testing-library/react";
import { QRCode } from "~/components/confirmation/qr-code";
 
describe("QRCode", () => {
  it("CE-UT04: renders canvas element", async () => {
    // Given: QRCode component with a value
    // When: Component mounts
    // Then: A canvas element is rendered for QR code
    const { container } = render(<QRCode value="test-token" size={160} />);
    const canvas = container.querySelector("canvas");
    expect(canvas).toBeInTheDocument();
  });
});

9. Cross-Plan Dependencies

PlanDepends OnShares
09-confirmation-exp01-foundationreservations table, getById query
10-cancellation-refund01-foundation, 09-confirmation-expCancelled reservations show cancelled status on confirmation page

This plan's confirmation page is the landing page after the OnePay payment webhook redirects the user. It requires:

  • api.reservations.getById query (from foundation/reservations)
  • reservation.token or reservation._id for QR code generation

10. Performance Considerations

  • PDF generation: renderToBuffer from @react-pdf/renderer is CPU-intensive. It runs on the server and can block the response. Consider:

    • Generating the PDF asynchronously and providing a download link
    • Using renderToStream for progressive rendering
    • Caching generated PDFs by reservationId (only if reservation data doesn't change)
  • QR code generation: The qrcode library runs in the browser (client-side). No server overhead. The canvas-based rendering is fast.

  • Calendar URL construction: String manipulation only — no performance concern.

  • Convex query on confirmation: This is a single getById call — very fast. No pagination or filtering needed.

  • Dynamic import of qrcode: Using dynamic import inside useEffect keeps the initial bundle small. Only load qrcode when the component mounts.


Consistency Audit: confirmation-plan

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
P0-1DownloadInvoiceButtonas any type assertion[FIXED] Changed to Zod safeParse() validation with invoiceDataSchema from ~/lib/schemas/confirmation
P0-2Confirmation pageDynamic URL segments ([occurrenceId])[FIXED] Uses searchParams (query params), not dynamic segments — spec violation noted
P0-3Schema fileSchema file not created[FIXED] Added Phase 1 Task 1 to create lib/schemas/confirmation.ts

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
P1-1Convex functionsconsole.log usage[FIXED] Uses consola for error logging
P1-2UI componentsHardcoded strings[FIXED] All use useTranslations/getTranslations
P1-3DownloadInvoiceButtonMissing useTransition[FIXED] Added for async download
P1-4ConfirmationPageMissing Suspense[FIXED] Added <Suspense> wrapper for DownloadInvoiceButton
P1-5UI iconsEmoji in UI[FIXED] Uses IconSymbol for all icons
P1-6DirectionsButtonHardcoded venue coords[FIXED] Venue coords from NEXT_PUBLIC_VENUE_LAT/LNG env vars
P1-7ConfirmationPage (server)useTranslations in server component[FIXED] Uses getTranslations from next-intl/server
P1-8CalendarButtonsICS content missing DTEND[FIXED] Added proper end time calculation

i18n Compliance

  • All user-facing strings use getTranslations (server) or useTranslations (client)
  • No hardcoded English strings in component code
  • Translation namespace confirmation.* covers all confirmation UI strings
  • Server components correctly use getTranslations from next-intl/server

Type Safety

  • Zod schemas defined for confirmation types (lib/schemas/confirmation.ts)
  • No as type assertions used anywhere in plan code
  • invoiceDataSchema used to type the invoice data structure
  • qrcode library dynamically imported to avoid SSR issues

Security

  • Venue coordinates from environment variables (not hardcoded)
  • PDF generation is server-side only
  • Webhook URL validation handled in cancellation plan

Design Tokens

TokenHexTailwind ClassUsage
background#1a1a1abg-[#1a1a1a]Page background
background-alt#2E2E2Ebg-[#2E2E2E]Booking recap card
accent#C5A059text-[#C5A059]Gold accents, QR dark color
gold-light#DEC89Etext-[#DEC89E]Hover states
text#e6e6e6text-[#e6e6e6]Body text
muted#808080text-[#808080]Secondary text, button labels
border#333333border-[#333333]Borders, dividers

Spec Violation (NOTED)

IssueSpec SaysPlan UsesReason
URL pattern/{locale}/booking/{occurrenceId}/confirmation (dynamic segment)/{locale}/booking?success=true&reservationId=... (query params)SSG-only project — no dynamic segments per Next.js 16 tech stack constraint