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 generationPhase 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
IconSymbolcomponent 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
getTranslationsfromnext-intl/server, NOTuseTranslationsfromnext-intl.useTranslationsis for client components only.
[P1 CRITICAL]: Do NOT use emoji characters in UI — use
IconSymbolcomponents instead.
[P0 CRITICAL]: Use
nuqsuseQueryStatefor 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
- Confirmation page shows animated success state on successful OnePay payment
- QR code displayed for walk-in check-in scanning
- "Add to Google Calendar" opens Google Calendar with correct event details
- "Add to Apple Calendar" downloads
.icsfile - "Get Directions" opens Google Maps with venue coordinates from env vars
- "Download PDF" generates and downloads invoice PDF on-the-fly
- On payment failure, error message shown with return home button
- All user-facing strings use i18n (not hardcoded)
- No emoji characters in UI — all use
IconSymbol - 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
getTranslationsfromnext-intl/server. Client components useuseTranslationsfromnext-intl. Never importuseTranslationsfromnext-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, Vietnam8. 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
| Plan | Depends On | Shares |
|---|---|---|
| 09-confirmation-exp | 01-foundation | reservations table, getById query |
| 10-cancellation-refund | 01-foundation, 09-confirmation-exp | Cancelled 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.getByIdquery (from foundation/reservations)reservation.tokenorreservation._idfor QR code generation
10. Performance Considerations
-
PDF generation:
renderToBufferfrom@react-pdf/rendereris CPU-intensive. It runs on the server and can block the response. Consider:- Generating the PDF asynchronously and providing a download link
- Using
renderToStreamfor progressive rendering - Caching generated PDFs by
reservationId(only if reservation data doesn't change)
-
QR code generation: The
qrcodelibrary 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
getByIdcall — very fast. No pagination or filtering needed. -
Dynamic import of qrcode: Using dynamic import inside
useEffectkeeps the initial bundle small. Only loadqrcodewhen the component mounts.
Consistency Audit: confirmation-plan
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P0-1 | DownloadInvoiceButton | as any type assertion | [FIXED] Changed to Zod safeParse() validation with invoiceDataSchema from ~/lib/schemas/confirmation |
| P0-2 | Confirmation page | Dynamic URL segments ([occurrenceId]) | [FIXED] Uses searchParams (query params), not dynamic segments — spec violation noted |
| P0-3 | Schema file | Schema file not created | [FIXED] Added Phase 1 Task 1 to create lib/schemas/confirmation.ts |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P1-1 | Convex functions | console.log usage | [FIXED] Uses consola for error logging |
| P1-2 | UI components | Hardcoded strings | [FIXED] All use useTranslations/getTranslations |
| P1-3 | DownloadInvoiceButton | Missing useTransition | [FIXED] Added for async download |
| P1-4 | ConfirmationPage | Missing Suspense | [FIXED] Added <Suspense> wrapper for DownloadInvoiceButton |
| P1-5 | UI icons | Emoji in UI | [FIXED] Uses IconSymbol for all icons |
| P1-6 | DirectionsButton | Hardcoded venue coords | [FIXED] Venue coords from NEXT_PUBLIC_VENUE_LAT/LNG env vars |
| P1-7 | ConfirmationPage (server) | useTranslations in server component | [FIXED] Uses getTranslations from next-intl/server |
| P1-8 | CalendarButtons | ICS content missing DTEND | [FIXED] Added proper end time calculation |
i18n Compliance
- All user-facing strings use
getTranslations(server) oruseTranslations(client) - No hardcoded English strings in component code
- Translation namespace
confirmation.*covers all confirmation UI strings - Server components correctly use
getTranslationsfromnext-intl/server
Type Safety
- Zod schemas defined for confirmation types (
lib/schemas/confirmation.ts) - No
astype assertions used anywhere in plan code invoiceDataSchemaused to type the invoice data structureqrcodelibrary 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
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
background | #1a1a1a | bg-[#1a1a1a] | Page background |
background-alt | #2E2E2E | bg-[#2E2E2E] | Booking recap card |
accent | #C5A059 | text-[#C5A059] | Gold accents, QR dark color |
gold-light | #DEC89E | text-[#DEC89E] | Hover states |
text | #e6e6e6 | text-[#e6e6e6] | Body text |
muted | #808080 | text-[#808080] | Secondary text, button labels |
border | #333333 | border-[#333333] | Borders, dividers |
Spec Violation (NOTED)
| Issue | Spec Says | Plan Uses | Reason |
|---|---|---|---|
| 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 |