Booking Experience

Source: Webmaster Brief — Booking System: “Experience & Add-ons” Step (2026-05-12) Doc Status: Excellent | ✓ All 6 checks passed

Overview

Inline page section on /booking. No modal. BookingSection owns a phase state machine:
GRID — EXPERIENCE — ADDONS — CHECKOUT — CONFIRMATION
Three experience cards: Ticket Only (650k/person), Dinner Show (990k/1.85m/3.5m by tier), VIP (sold out). Add-ons filtered by selected experience type. Bottles show discount tags vs. menu price. Admin can manage all add-ons and pricing without code changes.

3-Card Experience Selection

Three vertical cards, single-select (radio button behavior). Selecting a card highlights it and updates the booking footer bar total.
CardPriceAdd-ons Available
Ticket Only650,000 VND / personSignature Bundle + all bottles
Dinner Show990k Solo / 1,850k Duo / 3,500k Table of 4Bottles only
VIPSOLD OUT

Card 1 — Ticket Only

  • Base price: 650,000 VND per person
  • Quantity: Controlled by a quantity stepper on the card
  • Add-ons below card: Checkable secondary cards — free multi-selection, no exclusivity
Add-ons for Ticket Only:
Add-onPriceTag
Signature Bundle (1 signature cocktail + 1 signature snack)360,000 VNDGreen: “Save 80,000 VND”
Chevalier Brut Blanc de Blancs (Champagne, France)950,000 VNDGold: “-15% vs. menu price”
Francis Gillot Sauvignon Blanc 2024750,000 VNDGold: “-15% vs. menu price”
Chateau Les Martineau Bordeaux 2021750,000 VNDGold: “-15% vs. menu price”
Note: “Menu prices” for bottles are not yet officially published. Display only the add-on price with the discount tag. Full prices to be confirmed with Hamza.

Card 2 — Dinner Show

Price tiers (per reservation, not per person):
TierGuest CountPrice
Solo1990,000 VND
Duo21,850,000 VND
Table of 43-43,500,000 VND
Cap: Dinner Show is capped at 4 guests maximum. If guest count > 4, Table of 4 is pre-selected.
Auto-selection: The correct tier is pre-selected based on the guest count set via a stepper on the card:
  • 1 guest — Solo pre-selected
  • 2 guests — Duo pre-selected
  • 3-4 guests — Table of 4 pre-selected
Inclusions: Full 4-course dinner (Welcome + Chapter I Sea + Chapter II Fire + Chapter III Earth). Add-ons: Bottle add-ons only. Signature Bundle is hidden (meal is already included). Bottle caption: “Book your bottle in advance — guaranteed availability & preferential rates.”
Add-onPriceTag
Chevalier Brut Blanc de Blancs950,000 VNDGold: “-15% vs. menu price”
Francis Gillot Sauvignon Blanc 2024750,000 VNDGold: “-15% vs. menu price”
Chateau Les Martineau Bordeaux 2021750,000 VNDGold: “-15% vs. menu price”
Benjamin Mendy Cabernet Sauvignon 2024750,000 VNDGold: “-15% vs. menu price”

Card 3 — VIP

  • Status: SOLD OUT — always disabled
  • UI: Card is visible but non-selectable. Red “Sold Out” badge overlays the card.
  • Action: “Join the Waiting List” button links to WhatsApp or an email form
  • No add-ons shown

Discount Tags

Displayed as high-visibility badges on add-on cards:
  • Green badge for fixed savings (e.g., “Save 80,000 VND”)
  • Gold badge for percentage discounts (e.g., “-15% vs. menu price”)

Add-on Filtering Rules

Experience SelectedAdd-ons Visible
Ticket OnlySignature Bundle + all bottles
Dinner ShowBottles only (Signature Bundle hidden)
VIPNothing — user never reaches add-ons

Pricing

Ticket Only

total = (650,000 × guest_count) + sum(addon_prices)

Dinner Show

total = dinner_tier_price + sum(bottle_addon_prices)
No per-person pricing for Dinner Show. Price is all-inclusive (4-course dinner).

Surcharges

Day-of-week surcharges and small-party surcharges do not apply under this pricing model. (The brief supersedes the previous logic in payments.ts.)
  • Always visible at the bottom of the section when an event is selected
  • Displays: selected experience name + guest/dinner tier count + real-time total (base + add-ons)
  • Format: "Dinner Show — 2 guests — 2,800,000 VND"
  • Updates instantly on add-on selection
  • Becomes the checkout CTA in the Checkout phase

Page Architecture

PhaseComponentDescription
GRIDEventCard × NUpcoming events, “Book Now” triggers EXPERIENCE
EXPERIENCEExperienceCards3-card layout
ADDONSAddonCheckCard × NFiltered by experienceType
CHECKOUTCheckoutFormCustomer form + payment
CONFIRMATIONConfirmationDisplayQR code + success
Route: /booking is the single entry point. No route change during booking flow.

State Machine

Phases

type BookingPhase =
  | "GRID" // Event selection grid
  | "EXPERIENCE" // 3-card experience selection
  | "ADDONS" // Add-on selection
  | "CHECKOUT" // Customer form + payment
  | "CONFIRMATION"; // QR code + success

Transitions

FromToTrigger
GRIDEXPERIENCEBook Now click
EXPERIENCEADDONSContinue
ADDONSCHECKOUTContinue/Skip
CHECKOUTCONFIRMATIONPayment success
CONFIRMATIONGRIDBook another
any phaseprevious phaseBack button

State Ownership

BookingSection owns all state:
  • selectedEventId: string | null
  • currentPhase: BookingPhase
  • Draft data flows through ReservationDraftContext (Convex-backed)

Component Inventory

New Components

ComponentFilePurpose
ExperienceCardscomponents/booking/experience-cards.tsx3-card layout container
DinnerTierSelectorcomponents/booking/dinner-tier-selector.tsxSolo / Duo / Table of 4 radio toggle
VipSoldOutBadgecomponents/booking/vip-sold-out-badge.tsxRed “Sold Out” overlay + waiting list CTA
AddonCheckCardcomponents/booking/addon-check-card.tsxCheckable add-on card with discount tag
DiscountTagcomponents/booking/discount-tag.tsxGreen “Save X” / Gold “-X% vs. menu” badge
BookingFooterBarcomponents/booking/booking-footer-bar.tsxAlways-visible price bar + CTA
BottleCaptioncomponents/booking/bottle-caption.tsx”Book your bottle in advance…” text

Modified Components

ComponentChange
BookingSectionFull redesign — owns phase state machine, renders inline booking flow
ReservationDraftProviderAdd experienceType, dinnerTier to draft state
StepAddonsFilter add-ons by experienceType using listByExperienceType
CheckoutSummaryShow experienceType + dinnerTier in order summary
ConfirmationDisplayShow correct experience type in confirmation

Removed Components

ComponentReason
BookingModalReplaced by inline BookingSection
BookingModalContextReplaced by BookingSection state
StepIndicatorNo stepper in inline flow
TicketTypeOptionReplaced by ExperienceCards layout
GuestCountSelectorReplaced by DinnerTierSelector + inline stepper
ExperienceOptionRedundant with new card layout

Schema Changes

addOns — New Fields

addOns: defineTable({
  name: v.string(),
  description: v.string(),
  price: v.number(), // VND selling price
  referencePrice: v.optional(v.number()), // VND original menu price
  imageUrl: v.optional(v.string()),
  type: v.union(
    v.literal("COCKTAIL"),
    v.literal("FOOD"),
    v.literal("WINE"), // NEW — distinct from generic OTHER
    v.literal("BEVERAGE"),
    v.literal("UPGRADE"),
    v.literal("OTHER"),
  ),
  // Controls which experience cards this add-on appears in
  availableFor: v.array(
    v.union(v.literal("TICKET_ONLY"), v.literal("DINNER_SHOW")),
  ),
  displayOrder: v.number(), // Display order within each experience type
  enabled: v.boolean(),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index("by_enabled", ["enabled"])
  .index("by_available_for", ["availableFor"]);

experiences — VIP Fields

experiences: defineTable({
  // ...existing fields...
  vipEnabled: v.boolean(), // Whether VIP is available for this show
  vipCapacity: v.number(), // VIP seat capacity
  vipSoldOut: v.boolean(), // Runtime sold-out flag (manual toggle)
});

bookingDrafts — New Fields

bookingDrafts: defineTable({
  // ...existing fields...
  experienceType: v.optional(
    v.union(
      v.literal("TICKET_ONLY"),
      v.literal("DINNER_SHOW"),
      v.literal("VIP"),
    ),
  ),
  dinnerTier: v.optional(
    v.union(v.literal("SOLO"), v.literal("DUO"), v.literal("TABLE_OF_4")),
  ),
});

reservations — New Fields

reservations: defineTable({
  // ...existing fields...
  experienceType: v.optional(
    v.union(
      v.literal("TICKET_ONLY"),
      v.literal("DINNER_SHOW"),
      v.literal("VIP"),
    ),
  ),
  dinnerTier: v.optional(v.string()), // SOLO | DUO | TABLE_OF_4
});

Backend API

New Query: listByExperienceType

Returns add-ons filtered by experience type (uses the availableFor index).
export const listByExperienceType = zQuery({
  args: z.object({
    experienceType: v.union(v.literal("TICKET_ONLY"), v.literal("DINNER_SHOW")),
  }),
  handler: async (ctx, { experienceType }) => {
    return await ctx.db
      .query("addOns")
      .withIndex("by_available_for", (q) =>
        q.eq("availableFor", experienceType),
      )
      .filter((q) => q.eq("enabled", true))
      .collect();
  },
});

New Query: getEventWithVipStatus

Returns event with VIP status flags populated.
export const getEventWithVipStatus = zQuery({
  args: { eventId: zid("experienceEvents") },
  handler: async (ctx, { eventId }) => { ... }
});

Updated Mutation: createPending

export const createPending = zMutation({
  args: {
    eventId: zid("experienceEvents"),
    experienceType: v.union(
      v.literal("TICKET_ONLY"),
      v.literal("DINNER_SHOW"),
      v.literal("VIP")
    ),
    dinnerTier: v.optional(v.string()),  // SOLO | DUO | TABLE_OF_4
    quantity: v.number(),
    // addOns handled separately in updateWithAddOns
  },
  handler: async (ctx, args) => { ... }
});

Updated Mutation: updateWithAddOns

No changes to signature — addOns already stored on reservation. The experienceType and dinnerTier are set during createPending.

Updated payments.ts: createAddon / updateAddon

Update args to include new fields:
export const createAddon = zMutation({
  args: {
    name: z.string(),
    description: z.string(),
    price: z.number(),
    referencePrice: z.number().optional(),
    imageUrl: z.string().optional(),
    type: addonTypeEnum,
    availableFor: v.array(
      v.union(v.literal("TICKET_ONLY"), v.literal("DINNER_SHOW"))
    ),
    displayOrder: z.number(),
  },
  handler: async (ctx, args) => { ... }
});

export const updateAddon = zMutation({
  args: {
    id: zid("addOns"),
    name: z.string().optional(),
    description: z.string().optional(),
    price: z.number().optional(),
    referencePrice: z.number().optional(),
    type: addonTypeEnum.optional(),
    availableFor: v.array(...).optional(),
    displayOrder: z.number().optional(),
    enabled: z.boolean().optional(),
  },
  handler: async (ctx, args) => { ... }
});

Admin CRUD

OperationEndpointAuth
List all add-onspayments.listAllAdmin
List by experience typepayments.listByExperienceTypeAdmin
Create add-onpayments.createAddonAdmin
Update add-onpayments.updateAddonAdmin
Toggle enabledpayments.disableAddonAdmin

Admin UI: /admin/addons (new page)

  • Table view: name, type, price, referencePrice, availableFor, displayOrder, enabled
  • Inline edit for price, referencePrice, description
  • Toggle enabled/disabled
  • Create new add-on form
  • Filter by availableFor (Ticket Only / Dinner Show / All)

Admin UI: VIP Status on Experience

On /admin/experiences/[id]:
  • Toggle vipEnabled
  • Set vipCapacity
  • Manual vipSoldOut toggle

Files to Change

Schema

  1. packages/backend/convex/schema.ts — Add fields to addOns, experiences, reservations, bookingDrafts

Backend

  1. packages/backend/convex/domains/payments.tslistByExperienceType query; update createAddon/updateAddon args
  2. packages/backend/convex/domains/reservations.ts — Update createPending args to accept experienceType + dinnerTier

Frontend Hooks

  1. apps/frontend/hooks/booking/use-experience-selection.ts — Manages experienceType + dinnerTier selection

Frontend Components

  1. apps/frontend/components/home/booking-section.tsx — Full redesign with phase state machine
  2. apps/frontend/components/booking/experience-cards.tsx — New
  3. apps/frontend/components/booking/dinner-tier-selector.tsx — New
  4. apps/frontend/components/booking/vip-sold-out-badge.tsx — New
  5. apps/frontend/components/booking/addon-check-card.tsx — New
  6. apps/frontend/components/booking/discount-tag.tsx — New
  7. apps/frontend/components/booking/booking-footer-bar.tsx — New
  8. apps/frontend/components/booking/bottle-caption.tsx — New
  9. apps/frontend/components/booking/step-addons.tsx — Filter by experienceType
  10. apps/frontend/contexts/reservation-draft-context.tsx — Add experienceType, dinnerTier
  11. apps/frontend/components/booking/checkout-summary.tsx — Show experienceType + dinnerTier
  12. apps/frontend/components/booking/confirmation-display.tsx — Show correct experience type

Remove

  1. apps/frontend/components/booking/booking-modal.tsx
  2. apps/frontend/components/booking/ticket-type-option.tsx
  3. apps/frontend/components/booking/guest-count-selector.tsx
  4. apps/frontend/components/booking/experience-option.tsx
  5. apps/frontend/components/booking/step-indicator.tsx
  6. apps/frontend/contexts/booking-modal-context.tsx

Admin Pages

  1. apps/frontend/app/[locale]/dashboard/admin/addons/page.tsx — New admin add-on management

Out of Scope

  • Seat / zone selection
  • The French Mentalist separate flow
  • Zoho CRM sync for add-ons
  • WhatsApp waiting list integration (P2)
  • Automatic VIP capacity tracking (VIP sold-out is manual toggle)

Implementation Notes

Schema Changes (2026-05-12)

The following schema changes were implemented in packages/backend/convex/schema.ts:

addOns table

  • Replaced applicableTo (single value) with availableFor (array) for multi-experience support
  • Added referencePrice: number for displaying discount tags
  • Added displayOrder: number for sorting within experience type
  • Added WINE and BEVERAGE to the type enum
// New addOns fields
availableFor: v.array(
  v.union(v.literal("TICKET_ONLY"), v.literal("DINNER_SHOW")),
),
referencePrice: v.optional(v.number()),
displayOrder: v.number(),

experiences table

  • Added vipCapacity: number for VIP seat capacity

experienceEvents table

  • Added vipSoldOut: boolean for runtime sold-out flag (manual toggle)

reservations table

  • Added experienceType: TICKET_ONLY | DINNER_SHOW | VIP
  • Added dinnerTier: SOLO | DUO | TABLE_OF_4

bookingDrafts table

  • Added experienceType: TICKET_ONLY | DINNER_SHOW | VIP
  • Added dinnerTier: SOLO | DUO | TABLE_OF_4

Backend API Changes

payments.tslistByExperienceType query

New query to filter add-ons by experience type using the availableFor array:
export const listByExperienceType = zQuery({
  args: ListByExperienceTypeArgsSchema,
  handler: async (ctx, { experienceType }) => {
    const all = await ctx.db
      .query("addOns")
      .withIndex("by_enabled", (q) => q.eq("enabled", true))
      .collect();

    return all
      .filter((a) => a.availableFor.includes(experienceType))
      .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
  },
});

payments.tscreateAddon mutation

Updated to accept new fields:
  • availableFor: array (required)
  • displayOrder: number (required)
  • referencePrice: optional number

payments.tsupdateAddon mutation

Updated to accept new fields:
  • availableFor: array (optional)
  • displayOrder: number (optional)
  • referencePrice: optional number

reservations.tscreatePending mutation

Updated to accept new arguments:
  • experienceType: TICKET_ONLY | DINNER_SHOW | VIP (optional)
  • dinnerTier: SOLO | DUO | TABLE_OF_4 (optional)

Implementation Status

ComponentStatusNotes
Schema changes✅ ImplementedAll new fields added
listByExperienceType query✅ ImplementedUses availableFor array
createAddon/updateAddon✅ ImplementedNew fields included
createPending✅ ImplementedexperienceType/dinnerTier args added
Frontend components✅ Implemented3-card UI built with inline flow
Booking modal removal✅ ImplementedInline phase state machine replaces modal

New Components Created

The following components were created to implement the 3-card experience selection:
ComponentFilePurpose
DiscountTagcomponents/booking/discount-tag.tsxGreen/gold badge for discount tags
AddonCheckCardcomponents/booking/addon-check-card.tsxCheckable add-on card
BottleCaptioncomponents/booking/bottle-caption.tsxInformational text for bottle add-ons
VipSoldOutBadgecomponents/booking/vip-sold-out-badge.tsxSold out overlay for VIP
DinnerTierSelectorcomponents/booking/dinner-tier-selector.tsxSolo/Duo/Table of 4 radio toggle
ExperienceCardscomponents/booking/experience-cards.tsx3-card experience selection container
BookingFooterBarcomponents/booking/booking-footer-bar.tsxFixed price bar with CTA

Modified Components

ComponentChange
BookingSectionRedesigned with inline phase state machine (GRID → EXPERIENCE → ADDONS → CHECKOUT → CONFIRMATION)
CheckoutFormAdded reservationId prop support
ConfirmationDisplayAdded reservationId prop support

Notes

  • The BookingSection now owns the complete booking flow inline — no modal
  • Phase state machine: GRID (event selection) → EXPERIENCE (3-card selection) → ADDONS (filtered add-ons) → CHECKOUT → CONFIRMATION
  • VIP is displayed as sold out with waiting list CTA
  • createPending is called when transitioning from EXPERIENCE to ADDONS phase
  • updateWithAddOns is called when transitioning from ADDONS to CHECKOUT phase