Show 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 2-level Show Template + Occurrences architecture that powers the entire House of Legends platform. This is the foundation for the homepage carousel, programme page, show detail pages, and booking flow.
Architecture: Show Templates (Level 1) define the show definition once. Show Occurrences (Level 2) are specific date/time instances. This architecture enables batch generation of occurrences, independent date cancellation, and price overrides per date — critical for Hamza's operational needs.
Tech Stack: Next.js 16 App Router, Convex (PostgreSQL), real-time subscriptions via useQuery, Tailwind CSS v4.
Pricing Context:
- Base Dinner Theatre price: stored in
showTemplates.defaultDinnerPrice(e.g., 900,000 VND) - Base Show Only price: stored in
showTemplates.defaultShowOnlyPrice(e.g., 450,000 VND) - Per-occurrence overrides:
showOccurrences.dinnerPriceOverride/showOnlyPriceOverride - Surcharges (from package-bundle-pricing plan): Thu +50K, Fri +100K, Sat +150K, Sun +100K per person
Business Summary
What this does: Implements the 2-level Show Template + Occurrences architecture that powers the entire House of Legends platform. Show Templates define a show once; Occurrences are specific date/time instances that can be batch-generated, independently cancelled, or have price overrides.
Why it matters: This is the foundational data layer for all guest-facing pages (homepage carousel, programme page, show detail) and the booking flow. Real-time availability badges on show dates directly impact conversion rates and prevent overselling. Enables Hamza to manage the programme without touching code.
Time to implement: 8-12 days | Complexity: High
Dependencies: No dependencies on other plans. This plan is a prerequisite for booking-flow, package-bundle-pricing, table-pos-system, notifications-crm, and staff-operations — they all reference show templates or occurrences.
Context & Key Constraints
[P0 GAP]
staffMutation/adminMutationnot yet implemented. The existingconvex/auth.tsonly providesgetCurrentUser,upsertUser, andisAdminhelpers. Any plan referencingstaffMutationoradminMutationmust use plainmutationwith inline role checks viactx.auth.getUserIdentity()instead.
[P0 RULE] No dynamic URL segments. The booking flow uses nuqs URL state. The
[Book]button on the show detail page MUST navigate to/booking?step=tickets&occurrenceId=${occurrence._id}— NOT/booking/${occurrence._id}/tickets. Show detail URL is/shows?slug=slug(nuqs), NOT/shows/${slug}.
[P1 RULE] All user-facing strings use translation keys: Availability badge labels, button text, empty state messages, and all UI copy must use translation keys — never hardcoded English strings.
[P1 RULE] Structured logging: Use
consolainstead ofconsole.log. Import:import { consola } from "consola";
[P0 RULE] useQuery API calls: Never double-call API functions. Use
useQuery(api.shows.upcoming, { limit: 8 })NOTuseQuery(api.shows.upcoming({ limit: 8 })).
File Map
convex/
├── schema.ts # ALREADY EXISTS — add indexes if needed
├── auth.ts # EXISTS — only getCurrentUser, upsertUser, isAdmin (no staffMutation/adminMutation)
├── functions/
│ ├── shows.ts # ALREADY EXISTS — extend with upcoming query
│ └── occurrences.ts # ALREADY EXISTS — extend with upcoming query
apps/frontend/
├── app/[locale]/
│ ├── page.tsx # MODIFY — wire carousel to Convex upcoming query
│ ├── programme/page.tsx # CREATE — full show list
│ └── shows/page.tsx # CREATE — show detail (nuqs ?slug=, NOT dynamic [slug] route)
├── components/home/
│ └── upcoming-shows-section.tsx # MODIFY — accept typed Convex data
└── lib/
└── convex/
└── provider.tsx # ALREADY EXISTSPhase 1: Convex Backend — Show System
Task 1: Verify & Extend shows.ts
Files:
- Modify:
convex/functions/shows.ts
The existing shows.ts has basic CRUD. We need upcoming query for the homepage carousel.
- Step 1: Add
upcomingquery to shows.ts
Add this query after the existing listActive query:
// Get upcoming occurrences for homepage carousel (next 8 across all shows)
export const upcoming = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const today = new Date().toISOString().split("T")[0];
const occurrences = await ctx.db
.query("showOccurrences")
.withIndex("by_date_status", (q) =>
q.gte("date", today).eq("status", "SCHEDULED"),
)
.collect();
// Sort by date + time, take limit
occurrences.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
return dateCompare !== 0 ? dateCompare : a.time.localeCompare(b.time);
});
const limited = occurrences.slice(0, limit ?? 8);
// Enrich with template data
const results = [];
for (const occ of limited) {
const template = await ctx.db.get(occ.templateId);
if (template && template.status === "ACTIVE") {
results.push({ occurrence: occ, show: template });
}
}
return results;
},
});- Step 2: Run to verify it compiles
Run: npx convex dev --run "api.shows.upcoming({limit: 8})"
Expected: Returns array of {occurrence, show} objects or empty array if no data
- Step 3: Commit
git add convex/functions/shows.ts
git commit -m "feat(shows): add upcoming query for homepage carousel"Task 2: Extend occurrences.ts with Show-Specific Queries
Files:
-
Modify:
convex/functions/occurrences.ts -
Create:
convex/functions/occurrences.ts(add new queries) -
Step 1: Read existing occurrences.ts
cat convex/functions/occurrences.ts- Step 2: Add
byTemplatequery
// Get all occurrences for a specific show template
export const byTemplate = query({
args: { templateId: v.id("showTemplates") },
handler: async (ctx, { templateId }) => {
return await ctx.db
.query("showOccurrences")
.withIndex("by_template", (q) => q.eq("templateId", templateId))
.collect();
},
});- Step 3: Add
upcomingByTemplatequery
// Get upcoming SCHEDULED occurrences for a specific show (for show detail page)
export const upcomingByTemplate = query({
args: { templateId: v.id("showTemplates"), limit: v.optional(v.number()) },
handler: async (ctx, { templateId, limit }) => {
const today = new Date().toISOString().split("T")[0];
const occurrences = await ctx.db
.query("showOccurrences")
.withIndex("by_template_date", (q) =>
q.eq("templateId", templateId).gte("date", today),
)
.collect();
// Filter to SCHEDULED only
const scheduled = occurrences
.filter((o) => o.status === "SCHEDULED")
.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
return dateCompare !== 0 ? dateCompare : b.time.localeCompare(a.time);
});
return limit ? scheduled.slice(0, limit) : scheduled;
},
});- Step 4: Add
getAvailabilityquery for real-time scarcity signals
// Get availability info for an occurrence (for booking flow + show page)
export const getAvailability = query({
args: { occurrenceId: v.id("showOccurrences") },
handler: async (ctx, { occurrenceId }) => {
const occ = await ctx.db.get(occurrenceId);
if (!occ) return null;
const remaining = occ.actualCapacity - occ.bookedCount;
const template = await ctx.db.get(occ.templateId);
return {
occurrenceId: occ._id,
totalCapacity: occ.actualCapacity,
bookedCount: occ.bookedCount,
remaining,
status: occ.status,
// Badge: AVAILABLE / FEW_LEFT / SOLD_OUT — translated on frontend
badge:
remaining > 10 ? "AVAILABLE" : remaining > 0 ? "FEW_LEFT" : "SOLD_OUT",
showOnlyEnabled: occ.showOnlyEnabled,
dinnerPrice: occ.dinnerPriceOverride ?? template?.defaultDinnerPrice ?? 0,
showOnlyPrice:
occ.showOnlyPriceOverride ?? template?.defaultShowOnlyPrice ?? 0,
};
},
});- Step 5: Commit
git add convex/functions/occurrences.ts
git commit -m "feat(occurrences): add template queries and availability"Phase 2: Frontend — Homepage Carousel
Task 3: Modify Homepage to Use Real Convex Data
Files:
- Modify:
apps/frontend/app/[locale]/page.tsx - Create:
apps/frontend/components/home/upcoming-shows-section.tsx(or modify existing)
The current homepage likely has static/mock data. We need to wire it to Convex real-time data.
- Step 1: Read current homepage and carousel component
cat apps/frontend/app/\[locale\]/page.tsx
cat apps/frontend/components/home/upcoming-shows-section.tsx- Step 2: Modify page.tsx to pass Convex data
The homepage imports UpcomingShowsSection. You need to:
- Add
ConvexClientProviderto the locale layout if not present (checkapp/[locale]/layout.tsx) - In
page.tsx, fetch data:const upcoming = useQuery(api.shows.upcoming, { limit: 8 }); - Pass
upcomingto<UpcomingShowsSection shows={upcoming ?? []} />
- Step 3: Update UpcomingShowsSection to accept typed data
The component should accept:
type UpcomingShow = {
occurrence: {
_id: Id<"showOccurrences">;
date: string;
time: string;
status: "SCHEDULED" | "CANCELLED" | "SOLD_OUT";
bookedCount: number;
actualCapacity: number;
};
show: {
_id: Id<"showTemplates">;
title: string;
slug: string;
gallery: string[];
defaultDinnerPrice: number;
supportedTicketTypes: ("DINNER_THEATRE" | "SHOW_ONLY")[];
};
};Card display logic:
-
Hero image:
show.gallery[0]or placeholder -
Title:
show.title -
Date/time:
occurrence.date+occurrence.timeformatted with locale -
Availability badge: computed from
occurrence.actualCapacity - occurrence.bookedCount -
Price: "From X VND" using
show.defaultDinnerPrice -
CTA button: links to
/shows?slug=${show.slug}(navigates to show detail, not directly to booking) -
Step 4: Test in browser
Run dev server, navigate to homepage, verify carousel shows real data (or empty state if no occurrences exist yet).
- Step 5: Commit
git add apps/frontend/app/\[locale\]/page.tsx apps/frontend/components/home/upcoming-shows-section.tsx
git commit -m "feat(homepage): wire carousel to Convex upcoming query"Phase 3: Programme Page
Task 4: Create Programme Page (/programme)
Files:
-
Create:
apps/frontend/app/[locale]/programme/page.tsx -
Create:
apps/frontend/components/programme/programme-grid.tsx(if needed) -
Step 1: Create programme page
"use client";
import { useTranslations } from "next-intl";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useRouter } from "next/navigation";
export default function ProgrammePage() {
const t = useTranslations();
const router = useRouter();
// CORRECT: useQuery(api.shows.listActive) NOT useQuery(api.shows.listActive())
const shows = useQuery(api.shows.listActive);
if (!shows) {
return (
<div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
<p className="text-[#808080]">{t("shows.programme.loading")}</p>
</div>
);
}
return (
<div className="min-h-screen bg-[#1a1a1a] py-12">
<div className="max-w-7xl mx-auto px-6">
<h1 className="font-serif text-[#C5A059] text-4xl mb-8">
{t("shows.programme.title")}
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{shows.map((show) => (
<button
key={show._id}
onClick={() => router.push(`/shows?slug=${show.slug}`)}
className="bg-[#1a1a1a] border border-[#333] rounded-lg overflow-hidden hover:border-[#C5A059]/50 transition-colors text-left"
>
{show.gallery[0] && (
<div className="aspect-video bg-gray-800">
<img
src={show.gallery[0]}
alt={show.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-4">
<h2 className="font-serif text-[#C5A059] text-xl mb-2">
{show.title}
</h2>
<p className="text-[#e6e6e6]/60 text-sm mb-4 line-clamp-2">
{show.tagline}
</p>
<div className="flex justify-between items-center">
<span className="text-[#C5A059] font-bold">
{t("shows.fromPrice", { price: show.defaultDinnerPrice.toLocaleString() })}
</span>
<span className="text-sm text-[#808080]">
{t("shows.viewDates")}
</span>
</div>
</div>
</button>
))}
</div>
</div>
</div>
);
}- Step 2: Add route to routing.ts if needed
Check apps/frontend/routing.ts to ensure /programme route is registered.
- Step 3: Test page loads
Navigate to /{locale}/programme and verify grid renders.
- Step 4: Commit
git add apps/frontend/app/\[locale\]/programme/
git commit -m "feat(programme): add show listing page"Phase 4: Show Detail Page
Task 5: Create Show Detail Page (/shows?slug=slug — nuqs, no [slug] route)
Files:
- Create:
apps/frontend/app/[locale]/shows/page.tsx(single page, slug via nuqs) - Create:
apps/frontend/components/shows/show-hero.tsx - Create:
apps/frontend/components/shows/show-occurrence-list.tsx
This is the main conversion page — most important page on the site.
- Step 1: Create page with data fetching
"use client";
import { useQueryState } from "nuqs";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { ShowDetailClient } from "~/components/shows/show-detail-client";
export default function ShowDetailPage() {
const [slug] = useQueryState("slug", { defaultValue: "" });
// CORRECT: useQuery(api.shows.getBySlug, { slug }) NOT useQuery(api.shows.getBySlug({ slug }))
const show = useQuery(api.shows.getBySlug, { slug });
if (slug && !show) return null; // 404 handled by notFound in parent
if (!slug || !show) {
return (
<div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
<p className="text-[#808080]">{t("shows.detail.loading")}</p>
</div>
);
}
return <ShowDetailClient show={show} />;
}-
Step 2: ShowDetailClient component
-
Embedded video (YouTube/Vimeo): autoplay muted — use
<iframe>with?autoplay=1&mute=1 -
Title + tagline
-
"See dates" button: smooth scroll to occurrences section
-
Photo gallery: horizontal scroll or grid below hero
-
Step 3: ShowOccurrenceList — the key conversion component
Data: useQuery(api.occurrences.upcomingByTemplate, { templateId: show._id })
Render each occurrence as a row:
Friday May 2 7:30 PM ● 12 seats left [Book]- Day + Date + Time columns
- Availability dot: computed from remaining seats (color class from translation key)
- Availability text: translated from badge key
shows.availability.available,shows.availability.fewLeft,shows.availability.soldOut - [Book] button: MUST navigate to
/booking?step=tickets&occurrenceId=${occurrence._id}— usinguseRouterfromnext/navigation, NOT a dynamic route segment
"See more dates" button: Expand 5 more inline (show first 5, button shows next 5).
Critical: Clicking [Book] locks the date/time — no need to re-pick inside booking.
- Step 4: Availability badge with translation keys
import { useTranslations } from "next-intl";
function getBadge(remaining: number, t: (key: string) => string) {
if (remaining > 10)
return {
colorClass: "bg-green-500", // dot color class
labelKey: "shows.availability.available",
};
if (remaining > 0)
return {
colorClass: "bg-orange-500",
labelKey: "shows.availability.fewLeft",
labelParams: { count: remaining },
};
return {
colorClass: "bg-gray-500",
labelKey: "shows.availability.soldOut",
};
}
// In JSX:
<span className={`inline-block w-2 h-2 rounded-full ${colorClass}`} />
<span>{t(labelKey, labelParams)}</span>- Step 5: Test booking button navigation
Click [Book] → should navigate to /booking?step=tickets&occurrenceId=xxx
- Step 6: Commit
git add apps/frontend/app/\[locale\]/shows/
git commit -m "feat(show-detail): add show page with occurrence list"Phase 5: Verify Schema Consistency
Task 6: Cross-Check Schema vs Frontend Types
Files:
-
Read:
convex/schema.ts -
Read:
apps/frontend/lib/convex/_generated/api(generated types) -
Step 1: Ensure all frontend type imports match schema
After running npx convex dev, generated types are in convex/_generated/api.d.ts.
Key type checks:
-
showTemplates.statusis"ACTIVE" | "DRAFT" | "ARCHIVED" -
showOccurrences.statusis"SCHEDULED" | "CANCELLED" | "SOLD_OUT" -
showOccurrences.bookedCount— ensure it's updated when reservations confirm (not on PENDING) -
showTemplates.supportedTicketTypesis array of"DINNER_THEATRE" | "SHOW_ONLY" -
Step 2: Verify bookedCount is correct
Current schema reservations doesn't auto-update bookedCount on PENDING — it only does so on reservation creation. This is a business decision: do we want to hold seats on PENDING, or only count PAID?
Per TFB Section 3.1: "Availability badge: Available / Few left / Sold out" — real-time availability should reflect actual confirmable seats. The current implementation holds seats on PENDING (counts them), which prevents overselling but may reduce conversion (people hold seats without paying).
If the business wants to only count PAID seats, we need to:
- Remove
bookedCount++fromcreatePending - Add
bookedCount++inconfirmPayment - Decrement on
releaseExpiredandcancel
This is a product decision — flag to Hamza before implementing.
- Step 3: Commit any schema/function changes
Enrichment Sections
1. Zod Schemas
// convex/functions/shows.ts
import { z } from "zod";
export const ShowSlugSchema = z.object({
slug: z.string().min(1, "Slug is required"),
});
export const UpcomingQuerySchema = z.object({
limit: z.number().int().positive().optional(),
});
export const UpcomingByTemplateSchema = z.object({
templateId: z.string().min(1, "Template ID is required"),
limit: z.number().int().positive().optional(),
});
export const GetAvailabilitySchema = z.object({
occurrenceId: z.string().min(1, "Occurrence ID is required"),
});
export const CreateShowTemplateSchema = z.object({
title: z.string().min(1, "Title is required"),
slug: z.string().min(1, "Slug is required"),
tagline: z.string().optional(),
description: z.string().optional(),
gallery: z.array(z.string()).default([]),
videoUrl: z.string().url().optional(),
defaultDinnerPrice: z.number().nonnegative(),
defaultShowOnlyPrice: z.number().nonnegative().optional(),
actualCapacity: z.number().int().positive(),
supportedTicketTypes: z
.array(z.enum(["DINNER_THEATRE", "SHOW_ONLY"]))
.default(["DINNER_THEATRE"]),
status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]).default("DRAFT"),
});
export const UpdateShowTemplateSchema = z.object({
id: z.string().min(1),
title: z.string().min(1).optional(),
tagline: z.string().optional(),
description: z.string().optional(),
gallery: z.array(z.string()).optional(),
videoUrl: z.string().url().optional(),
defaultDinnerPrice: z.number().nonnegative().optional(),
defaultShowOnlyPrice: z.number().nonnegative().optional(),
actualCapacity: z.number().int().positive().optional(),
supportedTicketTypes: z
.array(z.enum(["DINNER_THEATRE", "SHOW_ONLY"]))
.optional(),
status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]).optional(),
});
export const CreateOccurrenceSchema = z.object({
templateId: z.string().min(1, "Template ID is required"),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format"),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:MM format"),
actualCapacity: z.number().int().positive(),
dinnerPriceOverride: z.number().nonnegative().optional(),
showOnlyPriceOverride: z.number().nonnegative().optional(),
showOnlyEnabled: z.boolean().default(false),
status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]).default("SCHEDULED"),
});
export const UpdateOccurrenceSchema = z.object({
id: z.string().min(1),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
time: z
.string()
.regex(/^\d{2}:\d{2}$/)
.optional(),
actualCapacity: z.number().int().positive().optional(),
dinnerPriceOverride: z.number().nonnegative().optional(),
showOnlyPriceOverride: z.number().nonnegative().optional(),
showOnlyEnabled: z.boolean().optional(),
status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]).optional(),
});2. Error Handling
// convex/functions/shows.ts + occurrences.ts
export const SHOW_ERROR_CODES = {
SHOW_NOT_FOUND: "SHOW_NOT_FOUND",
TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
OCCURRENCE_NOT_FOUND: "OCCURRENCE_NOT_FOUND",
OCCURRENCE_NOT_SCHEDULED: "OCCURRENCE_NOT_SCHEDULED",
SHOW_ONLY_NOT_ENABLED: "SHOW_ONLY_NOT_ENABLED",
GENERATION_FAILED: "GENERATION_FAILED",
} as const;
export type ShowErrorCode = keyof typeof SHOW_ERROR_CODES;| Function | Error Code | Message Key | Condition |
|---|---|---|---|
shows.getBySlug | SHOW_NOT_FOUND | errors.shows.showNotFound | Slug does not match any show |
shows.getBySlug | TEMPLATE_NOT_FOUND | errors.shows.templateNotFound | Template ID invalid |
occurrences.upcomingByTemplate | TEMPLATE_NOT_FOUND | errors.shows.templateNotFound | Template ID invalid |
occurrences.getAvailability | OCCURRENCE_NOT_FOUND | errors.shows.occurrenceNotFound | Occurrence ID invalid |
occurrences.upcomingByTemplate | OCCURRENCE_NOT_SCHEDULED | errors.shows.occurrenceNotScheduled | Occurrence is cancelled/sold-out |
| Admin mutations (create, update) | UNAUTHORIZED | errors.auth.unauthorized | Not admin or staff |
generateBatch | GENERATION_FAILED | errors.shows.generationFailed | Batch generation failed |
3. Convex Real-time Subscription Pattern
// Homepage carousel — all upcoming shows
// CORRECT: pass function reference, not call it
const upcoming = useQuery(api.shows.upcoming, { limit: 8 });
// Programme page — all active shows
const shows = useQuery(api.shows.listActive);
// Show detail page — single show
const show = useQuery(api.shows.getBySlug, { slug });
// Show detail page — occurrences for this show
// CORRECT: pass function reference, not call it
const occurrences = useQuery(api.occurrences.upcomingByTemplate, {
templateId: show._id,
limit: 5,
});
// Booking flow — availability for selected occurrence
// CORRECT: pass function reference, not call it
const availability = useQuery(api.occurrences.getAvailability, {
occurrenceId,
});4. Mobile/Responsive Considerations
| Component | Mobile Behavior |
|---|---|
| Homepage carousel | Horizontal scroll cards; visible fraction of next card |
| Programme grid | Single column on mobile; 2 columns on tablet; 3 on desktop |
| Show detail page | Full-width hero; stacked content; sticky book button |
| Occurrence list | Single column rows; date/time stacked; full-width book button |
| Gallery | Horizontal scroll; fullscreen lightbox on tap |
5. PWA / Offline Behavior
Not applicable — show pages are content pages that refresh on each visit. Real-time availability is critical, so no offline caching for these pages.
6. i18n / next-intl Requirements
{
"shows": {
"programme": {
"title": "Programme",
"loading": "Loading shows..."
},
"detail": {
"loading": "Loading show details...",
"seeDates": "See Dates",
"bookNow": "Book Now"
},
"availability": {
"available": "Available",
"fewLeft": "Only {count} seats left",
"soldOut": "Sold Out"
},
"fromPrice": "From {price} VND",
"viewDates": "View Dates & Book",
"seeMoreDates": "See more dates",
"showLessDates": "Show less"
},
"common": {
"days": {
"short": {
"0": "Sun",
"1": "Mon",
"2": "Tue",
"3": "Wed",
"4": "Thu",
"5": "Fri",
"6": "Sat"
}
},
"currency": "VND"
},
"errors": {
"shows": {
"showNotFound": "Show not found",
"templateNotFound": "Show template not found",
"occurrenceNotFound": "Show date not found",
"occurrenceNotScheduled": "This show date is no longer available",
"generationFailed": "Failed to generate show dates"
},
"auth": {
"unauthorized": "You must be signed in as staff or admin"
}
}
}Date formatting: Use Intl.DateTimeFormat with locale for date/time display:
const formattedDate = new Intl.DateTimeFormat(locale, {
weekday: "long",
month: "long",
day: "numeric",
}).format(new Date(occurrence.date));7. Environment-Specific Configuration
# Server-only (never exposed to client):
CLERK_SECRET_KEY= # Clerk secret key
# Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL= # Convex deployment URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= # Clerk publishable key8. TDD Test Cases
E2E Tests (Playwright):
// e2e/show-system.spec.ts
test("SS-E2E-1.1: Homepage displays upcoming show carousel", async ({
page,
}) => {
// Given: 3 active shows with upcoming occurrences exist
// When: Guest visits homepage
// Then: Carousel shows up to 8 occurrence cards with show title, date, time, and price
await page.goto("/");
await expect(
page.locator('[data-testid="upcoming-shows-carousel"]'),
).toBeVisible();
});
test("SS-E2E-1.2: Programme page shows all active shows", async ({ page }) => {
// Given: 5 active show templates exist
// When: Guest navigates to /programme
// Then: Grid displays 5 show cards with title, tagline, hero image, and CTA
await page.goto("/en/programme");
const cards = page.locator('[data-testid="show-card"]');
await expect(cards).toHaveCount(5);
});
test("SS-E2E-1.3: Show detail page loads with slug param", async ({ page }) => {
// Given: Show "A" exists with slug "show-a"
// When: Guest navigates to /shows?slug=show-a
// Then: Page shows video, description, gallery, and occurrence list
await page.goto("/en/shows?slug=show-a");
await expect(page.locator('[data-testid="show-hero"]')).toBeVisible();
await expect(page.locator('[data-testid="occurrence-list"]')).toBeVisible();
});
test("SS-E2E-1.4: Invalid slug shows 404", async ({ page }) => {
// Given: No show with slug "nonexistent" exists
// When: Guest navigates to /shows?slug=nonexistent
// Then: Page shows notFound()
await page.goto("/en/shows?slug=nonexistent");
await expect(page.locator('[data-testid="not-found"]')).toBeVisible();
});
test("SS-E2E-1.5: Book button navigates to booking flow", async ({ page }) => {
// Given: Show "A" has a scheduled occurrence with ID "occ123"
// When: Guest on show detail page clicks [Book] on that occurrence
// Then: Guest navigates to /booking?step=tickets&occurrenceId=occ123
await page.goto("/en/shows?slug=show-a");
await page.getByTestId("book-btn-occ123").click();
await expect(page).toHaveURL(/\/booking\?step=tickets&occurrenceId=occ123/);
});
test("SS-E2E-1.6: See more dates expands occurrence list", async ({ page }) => {
// Given: Show "A" has 12 upcoming occurrences
// When: Guest on show detail page clicks "See more dates"
// Then: 5 more occurrences appear inline, button updates to "Show less"
await page.goto("/en/shows?slug=show-a");
await page.getByTestId("see-more-dates-btn").click();
await expect(page.locator('[data-testid="occurrence-row"]')).toHaveCount(10);
});Component Tests (Vitest + RTL):
// __tests__/components/upcoming-shows-section.test.tsx
it("SS-CT-1.1: Renders all available occurrences with correct data", async () => {
// Given: Mock upcoming shows data with 3 occurrences
const mockData = [
{ occurrence: { date: "2026-05-15", time: "19:30", status: "SCHEDULED", bookedCount: 5, actualCapacity: 50 }, show: { title: "Show A", slug: "show-a", gallery: [], defaultDinnerPrice: 900000 } },
];
render(<UpcomingShowsSection shows={mockData} />);
// Then: Each occurrence shows date, time, availability badge
expect(screen.getByText("2026-05-15")).toBeInTheDocument();
expect(screen.getByText("7:30 PM")).toBeInTheDocument();
});
it("SS-CT-1.2: Availability badge shows green for available", async () => {
// Given: Occurrence with 25 remaining seats (out of 50)
const mockData = [{ occurrence: { bookedCount: 25, actualCapacity: 50 }, show: { title: "Show A" } }];
render(<UpcomingShowsSection shows={mockData} />);
// Then: Green badge with "Available" text
const badge = screen.getByTestId("availability-badge");
expect(badge).toHaveClass("bg-green-500");
expect(badge).toContainText("Available");
});
it("SS-CT-1.3: Availability badge shows orange for few left", async () => {
// Given: Occurrence with 8 remaining seats
const mockData = [{ occurrence: { bookedCount: 42, actualCapacity: 50 }, show: { title: "Show A" } }];
render(<UpcomingShowsSection shows={mockData} />);
// Then: Orange badge with "Only 8 seats left" text
const badge = screen.getByTestId("availability-badge");
expect(badge).toHaveClass("bg-orange-500");
expect(badge).toContainText("Only 8 seats left");
});
it("SS-CT-1.4: Sold out occurrence shows disabled Book button", async () => {
// Given: Occurrence with 0 remaining seats
const mockData = [{ occurrence: { _id: "occ1", bookedCount: 50, actualCapacity: 50, status: "SOLD_OUT" }, show: { title: "Show A", slug: "show-a" } }];
render(<UpcomingShowsSection shows={mockData} />);
// Then: Book button is disabled
const bookBtn = screen.getByTestId("book-btn-occ1");
expect(bookBtn).toBeDisabled();
});
it("SS-CT-1.5: Empty state shown when no upcoming shows", async () => {
// Given: Empty shows array
render(<UpcomingShowsSection shows={[]} />);
// Then: Empty state message or placeholder
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
});Backend Tests (Vitest):
// __tests__/convex/shows.test.ts
it("SS-BE-1.1: upcoming query returns only SCHEDULED occurrences", async () => {
// Given: Mixed SCHEDULED and CANCELLED occurrences in DB
// When: Calling api.shows.upcoming query
// Then: Only SCHEDULED occurrences returned
const result = await ctx.runQuery(api.shows.upcoming, { limit: 8 });
for (const { occurrence } of result) {
expect(occurrence.status).toBe("SCHEDULED");
}
});
it("SS-BE-1.2: upcoming query returns occurrences sorted by date then time", async () => {
// Given: Multiple upcoming occurrences
// When: Calling api.shows.upcoming query
// Then: Results sorted chronologically
const result = await ctx.runQuery(api.shows.upcoming, { limit: 8 });
for (let i = 1; i < result.length; i++) {
const prevDate = result[i - 1].occurrence.date;
const currDate = result[i].occurrence.date;
expect(prevDate.localeCompare(currDate) <= 0).toBe(true);
}
});
it("SS-BE-1.3: upcoming query enriches with ACTIVE show templates only", async () => {
// Given: Occurrences linked to ACTIVE and ARCHIVED templates
// When: Calling api.shows.upcoming query
// Then: Only occurrences linked to ACTIVE templates returned
const result = await ctx.runQuery(api.shows.upcoming, { limit: 8 });
for (const { show } of result) {
expect(show.status).toBe("ACTIVE");
}
});
it("SS-BE-1.4: getAvailability returns null for nonexistent occurrence", async () => {
// Given: Non-existent occurrence ID
// When: Calling api.occurrences.getAvailability
// Then: Returns null
const result = await ctx.runQuery(api.occurrences.getAvailability, {
occurrenceId: "nonexistent",
});
expect(result).toBeNull();
});
it("SS-BE-1.5: getAvailability computes AVAILABLE badge correctly", async () => {
// Given: Occurrence with 20 remaining seats
// When: Calling api.occurrences.getAvailability
// Then: Badge is "AVAILABLE" and remaining is 20
const result = await ctx.runQuery(api.occurrences.getAvailability, {
occurrenceId: "occWith20Remaining",
});
expect(result?.badge).toBe("AVAILABLE");
expect(result?.remaining).toBe(20);
});
it("SS-BE-1.6: getAvailability computes FEW_LEFT badge correctly", async () => {
// Given: Occurrence with 5 remaining seats
// When: Calling api.occurrences.getAvailability
// Then: Badge is "FEW_LEFT" and remaining is 5
const result = await ctx.runQuery(api.occurrences.getAvailability, {
occurrenceId: "occWith5Remaining",
});
expect(result?.badge).toBe("FEW_LEFT");
expect(result?.remaining).toBe(5);
});
it("SS-BE-1.7: getAvailability computes SOLD_OUT badge correctly", async () => {
// Given: Occurrence with 0 remaining seats
// When: Calling api.occurrences.getAvailability
// Then: Badge is "SOLD_OUT" and remaining is 0
const result = await ctx.runQuery(api.occurrences.getAvailability, {
occurrenceId: "occWith0Remaining",
});
expect(result?.badge).toBe("SOLD_OUT");
expect(result?.remaining).toBe(0);
});
it("SS-BE-1.8: upcomingByTemplate returns only SCHEDULED occurrences for given template", async () => {
// Given: Template "A" with 3 SCHEDULED and 1 CANCELLED occurrences
// When: Calling api.occurrences.upcomingByTemplate with templateId
// Then: Returns only SCHEDULED occurrences (3), sorted by date
const result = await ctx.runQuery(api.occurrences.upcomingByTemplate, {
templateId: "templateA",
limit: 5,
});
expect(result).toHaveLength(3);
for (const occ of result) {
expect(occ.status).toBe("SCHEDULED");
}
});
it("SS-BE-1.9: upcomingByTemplate returns empty array for template with no upcoming occurrences", async () => {
// Given: Template with all past/cancelled occurrences
// When: Calling api.occurrences.upcomingByTemplate
// Then: Returns empty array
const result = await ctx.runQuery(api.occurrences.upcomingByTemplate, {
templateId: "templateWithNoUpcoming",
});
expect(result).toHaveLength(0);
});9. Cross-Plan Dependencies
| Dependency | Plan | Shared Schema |
|---|---|---|
| Required by | package-bundle-pricing | showTemplates.defaultDinnerPrice, showOccurrences.dinnerPriceOverride |
| Required by | notifications-crm | showTemplates.title for email content |
| Depends on | booking-flow | Booking flow links from show detail page |
| Required by | staff-operations | Show occurrences linked to reservations |
| Required by | table-pos-system | showOccurrences used in reservation linkage |
| Schema shares | showTemplates, showOccurrences | Both show-system and booking-flow use these tables |
10. Performance Considerations
| Scenario | At Scale (10 shows, 1000 occurrences, 10K daily visitors) |
|---|---|
| Homepage carousel | upcoming query limited to 8 results; simple DB read; < 50ms |
| Programme page | listActive returns ~10 shows; cached by Convex; < 20ms |
| Show detail | upcomingByTemplate for single show; ~30 results max; < 30ms |
| Availability badge | getAvailability per occurrence; simple calculation; < 10ms |
| Real-time updates | Convex subscription for availability changes; < 500ms latency |
| Slug lookup | shows.getBySlug uses by_slug index; < 10ms |
Database indexes required (verify in schema.ts):
showOccurrences.by_date_status— forupcomingquery (date >= today, status = SCHEDULED)showOccurrences.by_template_date— forupcomingByTemplatequery (templateId + date >= today)showTemplates.by_slug— forgetBySlugquery (slug unique lookup)showTemplates.by_status— forlistActivequery (status = ACTIVE)
Acceptance Criteria
- Homepage carousel shows next 8 upcoming occurrences across all ACTIVE shows, real-time via Convex subscription
- Programme page (
/programme) lists all ACTIVE show templates in a grid - Show detail page (
/shows?slug=slug) displays:- Embedded video autoplay
- Full description + gallery
- Upcoming occurrences as a vertical list with availability badges
- [Book] button per occurrence — navigates to
/booking?step=tickets&occurrenceId=xxx
- "See more dates" expands 5 more occurrences inline
- All data is real-time — admin changes to occurrence price/status reflect immediately on the public site
- Scarcity signals show correct seat counts
User Stories
| ID | As a... | I want to... | So that... | Priority |
|---|---|---|---|---|
| SS-US01 | Guest | Browse upcoming shows on the homepage carousel | I can see what's available and book quickly | Must |
| SS-US02 | Guest | View the programme page with all shows | I can compare shows and plan my visit | Must |
| SS-US03 | Guest | Select a specific show and see all its dates | I can find a convenient date for my group | Must |
| SS-US04 | Guest | Book a specific show occurrence directly | I don't have to re-select the date during booking | Must |
| SS-US05 | Guest | See real-time seat availability on show dates | I know which dates have space before I commit | Should |
| SS-US06 | Guest | See pricing for dinner theatre vs show-only options | I can choose the ticket type that fits my budget | Must |
| SS-US07 | Admin | Create a new show template with all details | I can define shows once and generate many occurrences | Must |
| SS-US08 | Admin | Batch-generate 30 occurrences for a show | I don't have to create each date manually | Must |
| SS-US09 | Admin | Override price for a specific occurrence | I can offer discounts or premium pricing for special dates | Should |
| SS-US10 | Admin | Cancel a specific occurrence without affecting others | I can manage schedule changes independently | Must |
| SS-US11 | Guest | See availability badges update in real-time | I can trust the seat count is accurate | Should |
Test Scenarios
| ID | Scenario | Given | When | Then |
|---|---|---|---|---|
| SS-TS01 | Homepage carousel loads | There are 3 upcoming occurrences across 2 active shows | Guest visits homepage | Carousel shows 3 cards with correct show titles, dates, times, and prices |
| SS-TS02 | Homepage carousel empty state | There are no upcoming occurrences | Guest visits homepage | Carousel shows empty state message or placeholder |
| SS-TS03 | Programme page grid | There are 5 active show templates | Guest navigates to /programme | Grid displays 5 show cards with title, tagline, hero image, and CTA button |
| SS-TS04 | Programme page links | A show card is displayed | Guest clicks "View dates & book" | Guest navigates to /shows?slug={slug} |
| SS-TS05 | Show detail page loads | Show template "A" exists with slug "show-a" | Guest navigates to /shows?slug=show-a | Page shows video, description, gallery, and occurrence list |
| SS-TS06 | Occurrence list displays | Show "A" has 8 upcoming occurrences | Guest views show detail page | All 8 occurrences display with date, time, and availability badge |
| SS-TS07 | Book button navigation | An occurrence with ID "occ123" exists | Guest clicks [Book] on that occurrence | Guest navigates to /booking?step=tickets&occurrenceId=occ123 |
| SS-TS08 | Availability badge green | An occurrence has 25 seats remaining out of 50 | Guest views occurrence row | Badge shows green dot and translated "available" text |
| SS-TS09 | Availability badge orange | An occurrence has 8 seats remaining | Guest views occurrence row | Badge shows orange dot and translated "few left" text |
| SS-TS10 | Availability badge sold out | An occurrence has 0 seats remaining | Guest views occurrence row | Badge shows grey dot and translated "sold out" text, Book button is disabled |
| SS-TS11 | See more dates expands | A show has 12 occurrences, 5 are shown initially | Guest clicks "See more dates" | 5 more occurrences appear inline, button updates to "Show less" |
| SS-TS12 | Admin creates show template | Admin is logged into admin panel | Admin fills in show form and saves | New show template appears in programme page |
| SS-TS13 | Admin batch generates occurrences | A show template exists | Admin selects 30 dates and clicks generate | 30 new occurrences appear in the show's occurrence list |
| SS-TS14 | Admin price override | An occurrence exists with default dinner price 900,000 | Admin sets dinnerPriceOverride to 1,200,000 | Guest sees 1,200,000 VND on show page, not 900,000 |
| SS-TS15 | Real-time seat count update | A guest books a seat (seat count goes from 15 to 14) | Another guest views the same show | Seat count shows 14, not the stale 15 |
Consistency Audit: show-system
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | All useQuery calls | useQuery(api.fn({ args })) — double-call pattern | Changed to useQuery(api.fn, { args }) — pass function reference, not call |
| 2 | Phase 1 Task 1 | Test code referenced ctx.runQuery(api.shows.upcoming, ...) but showed incorrect double-call in inline test examples | Fixed inline test examples to use correct pattern |
| 3 | File Map | References staffMutation/adminMutation from convex/auth.ts which don't exist | Removed references; use plain mutation with inline role checks via ctx.auth.getUserIdentity() |
| 4 | Phase 5 | Schema consistency section references "Convex schema types" which require generated types | Flagged as P0 GAP — depends on npx convex dev generating types first |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Phase 3, ProgrammePage | Hardcoded text replaced with useTranslations | Added useTranslations for all user-facing strings |
| 2 | Phase 3, ProgrammePage | Hardcoded color values replaced with Tailwind classes | Used bg-[#1a1a1a], text-[#C5A059] etc. |
| 3 | Global | No consola usage for logging | Added structured logging note for admin mutations |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation/authenticatedMutation not in convex/auth.ts | Use plain mutation with inline role checks until auth helpers are implemented |
| 2 | Generated Convex types (convex/_generated/api.d.ts) required for full type safety | Run npx convex dev to generate types before implementing frontend components |
Schema Consistency Check
shows.upcominguseswithIndex("by_date_status")— verify this index exists inschema.tsshows.getBySlugusesby_slugindex — verify this index existsoccurrences.upcomingByTemplateuseswithIndex("by_template_date")— verify this index existsshowTemplates.statusfield type matches"ACTIVE" | "DRAFT" | "ARCHIVED"used in frontendshowOccurrences.statusfield type matches"SCHEDULED" | "CANCELLED" | "SOLD_OUT"used in frontend