Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Birthday Add-On — Frontend PRD

Companion to 2002_Birthday_AddOn_20260716170000.md (the product PRD). This document captures the frontend side of the Birthday Add-On feature across book-bite (partner portal, in v1) and hh-pegasus (diner-facing, deferred to v2 — see 📦 v1 Scope (Revised)).

OwnerFrontend (book-bite: restaurant-facing; hh-pegasus: diner-facing)
Backend PRD2002_Birthday_AddOn_20260716170000.md
Backend engineering spec2026-07-30-birthday-add-on-hh-server.md
Book-Bite API / FE plan2026-08-06-birthday-add-on-book-bite-api.md and 2026-08-06-birthday-add-on-api-contract.md
Glossary📕 Glossary in PM PRD
StatusDraft — book-bite section active (v1); hh-pegasus section gated on FE access

📦 Scope split

book-bite (in v1)

  • Flow 8 — Partner-portal Create/Edit Add-On screen, including all validation rules from PM PRD.
  • Flow 9 — Staff-side create/edit booking: mutual-exclusivity for birthday add-on selection.

hh-pegasus (deferred to v2)

  • Flow 1 — Discovery icon on 4 restaurant-card components.
  • Flow 2 — Birthday Celebration section + deep-link scroll.
  • Flow 4 — Cart swap (exactly-one birthday add-on).
  • Flow 5 — Checkout authority (server-side; FE just renders based on authoritative response).
  • Flow 6 — Free add-on unlock at occasion selection.
  • Flow 7 — Confirmation line item.

Implementation tickets in v1 cover book-bite only. Pegasus specs below are documented for reference and to be re-baselined when pegasus access is restored.


1. Component strategy

Flow 8 form (book-bite)

Decision (per grill-with-docs FE round 1): build fresh, do not extend the existing regular-add-on form.

Reasoning:

  • Birthday add-on has unique fields (Items Included repeatable list with 3 initial rows, at least one Cake row, free-vs-paid toggle via price=0, image-upload rules + new dimensions).
  • Validation rules diverge from regular add-ons (price 0 is meaningful; at least one active Cake row is required; the list can be expanded or reduced but must retain at least one row).
  • The flow is logically separate — birthday add-ons only appear in the Birthday section, not mixed in the regular add-on catalog. Coupling the form to the regular add-on form would force FE to branch on add_on_type everywhere, making future add_on_type values (anniversary, business dinner) painful to add.

New files (within book-bite/src/pages/addOns/birthday/ or similar — exact TBD at /codebase-design time):

src/pages/addOns/birthday/
├── BirthdayAddOnList.vue          # list with delete-with-confirm
├── BirthdayAddOnForm.vue          # create + edit (new vs edit via prop)
├── components/
│   ├── ItemsIncludedRepeater.vue  # 3 initial rows; add/remove with minimum-one-row guard
│   ├── ImageUploaderCover.vue     # cover image (420×240, 10MB)
│   ├── ImageUploaderMenu.vue      # menu image (2048×2048, 10MB)
│   ├── FreeTreatToggle.vue        # informational helper when price == 0
│   └── FormValidationSummary.vue  # top-of-form error summary
└── composables/
    ├── useBirthdayAddOnForm.js    # form state + validation + submit
    └── useFreeTreatDetection.js   # pure: price === 0 ⇒ free mode messaging

Flow 9 staff-side select (book-bite)

Decision (per grill-with-docs FE round 2): disable siblings with tooltip reason, do not hide them.

Behavior: once staff pick one birthday add-on, all other birthday add-ons in the list stay visible but become disabled. Tooltip on the disabled item: "You can only choose one birthday add-on per booking". Non-birthday add-ons in the same list are completely unaffected (still selectable freely).

Flow 8 vs Flow 9 component reuse

ComponentReuse from existing?
Image upload widgetReuse the existing cover-image uploader (matches coverImageTooltip translation: Recommended size: 420 x 240px). Menu image may need a new variant since 2048×2048 is birthday-specific.
Form validation displayReuse existing inline-error patterns; consider the same FormValidationSummary if existing add-on form has one.
Card displayReuse existing add-on card component if it takes arbitrary add_on props (since the data shape is identical).
Repeater for items-includedNew component (3 initial rows, with add/remove and minimum-one-row guard).
Date-range / image-upload rule from existing add-on formMay share; verify at codebase-design time.

hh-pegasus (deferred)

When pegasus is back in scope, expect to reuse:

  • Existing add-on card component (data shape identical, only surface context differs).
  • Existing cart-line component.
  • Existing occasion selector (already reads “occasion” field used by Occasion Vault — verify at integration time).

2. State machines (per flow)

Flow 8 — Create Add-On (book-bite)

[ idle ]
   │ user clicks "New Birthday Add-On"
   ▼
[ editing ] ◀─────────┐
   │ user clicks Save │ user clicks Edit on existing row
   ▼                  │
[ submitting ]       │
   │ success         │
   ▼                  │
[ success ] ─────────┘
   │
   │ user navigates away / clicks "New" again
   ▼
[ dirty_unsaved ]   ←── prompt "Unsaved changes. Discard / Continue editing?"
   │
   ▼
[ idle ]

States:

  • idle: list rendered, no row selected.
  • editing: form mounted, user has interacted with at least one field. Image uploads pending.
  • submitting: form submitted, backend response pending.
  • success: save succeeded; toast + redirect to list.
  • dirty_unsaved: form has unsaved changes; user tries to leave the page or restart; show confirm prompt per PM PRD rule.

Dirty-state guard: track via dirty-flag on first form-field change. Reset on successful submit or explicit Reset button.

Flow 8 — Edit Add-On (book-bite)

Same state machine as create, initial state editing prepopulated with fetched data.

Flow 9 — Staff-side booking (book-bite)

Birthday add-on selection state lives inside the existing “select add-ons” substep of staff booking create/edit:

[ add_ons_unselected ]
   │ staff picks birthday add-on A
   ▼
[ birthday_add_on_selected: A ]      ← non-birthday add-ons still selectable freely
   │ staff picks another birthday add-on B
   ▼
[ mutual_exclusivity_violation_attempt ]  ← disable birthday siblings with tooltip
                                           ← non-birthday add-ons still selectable
   │ staff picks B (somehow forced past disabled state, e.g. via stale DOM)
   ▼
[ swap_attempted ]  ← backend rejects (Flow 5 server-side authority);
                       FE swaps instantly for smooth feel (per PM PRD Flow 4)
   │
   ▼
[ birthday_add_on_selected: B ]

Mutual-exclusivity violation is enforced both client-side (disabled siblings + tooltip) and server-side (checkout authority, see hh-server Integration doc).

hh-pegasus (deferred)

State machines for Flow 1, 2, 4, 5, 6, 7 are deferred. Each will follow the existing pattern: idle → interactive → submitting → success/error, with relevant swap and unlock transitions for the free-add-on path.


3. Form behavior & validation

Validation library — follow project convention

Per grill-with-docs FE round 1, FE team picks the validation library that matches the existing book-bite code (no new dependency added unless required). Candidates already known to exist:

  • Vue i18n for translation strings.
  • Existing inline-error pattern (check book-bite/src/components/filters/ for examples).
  • Possible Vue + custom validators; check existing add-on form for precedent.

Field-level rules (UX-side)

Match backend validation in PM PRD Flow 8 + Flow 9 — server is the authority. FE rules match server rules so the user gets immediate feedback, not just server rejection.

FieldRule (UX-side)Error message key
Cover imageRequired for create, optional for edit. Max 10MB. Recommended 420×240px. JPG, PNG, TIFF, HEIC, WEBP.birthdayAddOn.errors.coverImage
Menu imageOptional. Max 10MB. Recommended 2048×2048px. Same formats.birthdayAddOn.errors.menuImage
PriceRequired. Zero allowed. Decimals allowed.birthdayAddOn.errors.priceRequired / priceInvalid
Time in advanceRequired (defaults to “Follow Restaurant Level”).birthdayAddOn.errors.timeInAdvanceRequired
Require prepaymentOptional checkbox; defaults to off. When enabled, at least one payment type is required.birthdayAddOn.errors.paymentTypeRequired
Payment typesMulti-select, populated from GET /restaurant_packages/payment_types; multiple types are allowed.birthdayAddOn.errors.paymentTypeRequired
TagOptional, autocomplete from existing tag list.(no error typically)
Items IncludedStarts with 3 rows. At least one active row must be Cake; rows can be added or removed while at least 1 row remains.birthdayAddOn.errors.cakeRequired
Name (TH/EN/CN)Required for at least one locale.birthdayAddOn.errors.nameRequired
Description (per locale)Optional but recommended.(no error)
T&COptional image.(no error)
Restaurant scopeSingle login defaults to its restaurant. Group-login staff can see birthday add-ons across accessible restaurants, filter by branch or restaurant_id, and create against the selected restaurant. If create omits or sends a blank top-level restaurant_id, the staff default restaurant is used.Restaurant-scope authorization error

Free-treat mode UI

When price === 0, show helper text immediately under the price field:

“This add-on will be offered as a complimentary treat. Diner will see it only after selecting ‘Birthday’ as their occasion at checkout.”

Helper text key: birthdayAddOn.freeTreat.helperText.

Payment settings parity with Admin

Book-Bite must use the same FE state and API keys as Admin Add-On Settings:

FE stateAPI keyContract
requirePrepaymentadd_on[require_prepayment]Boolean, default false.
paymentTypesadd_on[add_on_payment_types_attributes]Array of selected integer payment_type_id values; multiple selections are supported.
minBookingTimeInAdvanceadd_on[minimum_booking_time_in_advance]Integer minutes; 0 means Follow Restaurant Level.

When editing, retain the selected payment rows’ junction id values. New selections submit { payment_type_id }; removed existing selections submit { id, payment_type_id, _destroy: true }. Turning prepayment off hides the selector, clears the local selection, and destroys persisted rows. Turning it on without selecting at least one type blocks save with the same validation behavior as Admin.

The payment selector uses the existing GET /restaurant_packages/payment_types partner API, which applies currency-specific filtering and returns { id, name }. The Birthday Add-On response only returns selected payment_types rows for edit hydration. The time-in-advance selector reuses Admin’s option values: 0, 15/30/45 minutes, then hourly values in 15-minute increments. The Admin-only 1000000 Unlimited sentinel is excluded from the normal Birthday Add-On form.

Image upload UX (round 2 answer)

Per grill-with-docs FE round 2, follow the existing pattern in book-bite (whatever the existing form does). Verified reference: existing translation key coverImageTooltip already says: "Recommended size: 420 x 240px" — covers the new birthday spec without changes. Menu image widget is new; tooltip key is birthdayAddOn.coverImage.tooltip and birthdayAddOn.menuImage.tooltip.


4. Analytics events (book-bite, v1)

Per grill-with-docs FE round 2, standard payload schema (restaurant_id + add_on_id + context per event), matching the team’s existing analytics conventions.

v1 — book-bite only

EventWhen firedPayload (standard + context)
birthday_addon_createdRestaurant creates a birthday add-on successfully (after backend confirms save).restaurant_id, add_on_id, add_on_type='birthday', price_paid, currency, is_free (= price === 0), items_count

Tracking implementation: extend the existing analytics plugin in book-bite/src/plugins/google-analytics.js (or wherever GA events are dispatched). Track should include the standard analytics fields (hitType: 'event', eventCategory: 'birthday_addon', eventAction: 'created', eventLabel: <add_on_id>).

v2 — hh-pegasus, deferred

EventPayload notes
birthday_icon_impressionrestaurant_id, add_on_id (or array, if restaurant has multiple add-ons), position (where on card)
birthday_icon_clickrestaurant_id, add_on_id, position, source_card (‘homepage’ | ‘search’ | ‘group-landing-page’ | ‘bite-list’)
birthday_section_viewrestaurant_id, scroll_position, source (deeplink query param vs scroll)
birthday_add_to_cartrestaurant_id, add_on_id, quantity, price_paid, is_free
birthday_occasion_selectedrestaurant_id, occasion (= ‘birthday’, but track string explicitly)
birthday_free_addon_addedrestaurant_id, add_on_id, price_paid (=0), trigger (‘occasion_selected’)
birthday_checkout_successrestaurant_id, reservation_id, booking_id, add_on_id, quantity, price_paid, total_amount, payment_method

When pegasus is in scope, FE team decides which analytics backend (CleverTap vs GA vs both) — keep the same event names so existing dashboards remain compatible.


5. i18n — copy + keys

Source of truth

Per grill-with-docs FE round 2: both — Google Spreadsheet is canonical (per 2002_AddOn PRD FAQ), YAML / JSON locale files generated from it. Pattern already used for existing add-on translations in book-bite.

Spreadsheet reference: https://docs.google.com/spreadsheets/d/1TQelRnKn6iepFavj09Nyig9CWyQycGqHVvWZHeJie1Q/edit?usp=sharing (existing add-on sheet). Extend with a tab/sheet for Birthday Add-On copy.

Key namespace

Use the birthdayAddOn namespace (within the package / addOn parent depending on which fits book-bite’s i18n structure).

KeyENTHCN
birthdayAddOn.titleBirthday Add-On(TBD by Localization)(TBD by Localization)
birthdayAddOn.listHeadingBirthday Celebration——
birthdayAddOn.actions.newNew Birthday Add-On——
birthdayAddOn.actions.editEdit Birthday Add-On——
birthdayAddOn.actions.deleteDelete——
birthdayAddOn.actions.delete.confirmTitleDelete this birthday add-on?——
birthdayAddOn.actions.delete.confirmBodyThis add-on will be removed from the restaurant page immediately. This cannot be undone.——
birthdayAddOn.form.cakeRowCake (required)——
birthdayAddOn.freeTreat.helperTextThis add-on will be offered as a complimentary treat. Diner will see it only after selecting ‘Birthday’ as their occasion at checkout.——
birthdayAddOn.mutualExclusivity.tooltipYou can only choose one birthday add-on per booking.——
birthdayAddOn.errors.groupLoginBlockedLegacy error retained for compatibility; group-login status does not block birthday add-on access.——
birthdayAddOn.errors.priceRequiredPrice is required.——
birthdayAddOn.errors.cakeRequiredThe first item must be “Cake”.——

(TH / CN values filled by Localization team — content/localization Aug 7 in PM PRD launch checklist.)

What copy is in v1 vs deferred

v1 needs only book-bite keys (above table). pegasus-side copy (the section header “Birthday Celebration”, card copy, swap confirmation toast, free add-on offer at checkout, etc.) can be deferred to v2 — but the keys should be reserved now so they don’t collide with future work.


6. Edge cases (FE-side handling)

Edge caseHandling
Two pre-existing cart bugs (per PM PRD Risks section: “Add” button doesn’t update; “Add” redirects to detail unexpectedly)Out of v1 scope (pegasus-side), but book-bite’s staff-side add-on selection MUST NOT inherit these — implement the Flow 9 mutual-exclusivity from scratch using the existing add-on select pattern.
Discovery icon delayIcon source is dynamic per add-on (per PM PRD Flow 1). Book-bite’s add-on list page does not render the icon, but the data needed (has_birthday_add_on, birthday_add_on_ids[]) must be ready and served on every restaurant API response so pegasus can immediately consume it.
Group-login access mid-formIf user loses access (admin changes role) mid-form, the next API call will 403; redirect to a friendly error state (no destructive cleanup, since form data was autoloaded).
Image upload timeout / failureShow inline retry on the image preview block; do not block form submit (cover image optional on edit, menu image optional).
Form dirty + browser back/closeShow beforeunload warning when dirty (PM PRD: “If there are unsaved changes, prompt the user before exiting”).
Stale data after rapid re-saveSubmit button shows spinner + disable; optimistic update not used (avoid wrong-state rollback).
Restaurant has many birthday add-ons (>10)Paginate in list view; default page size 25 (matches existing add-on list).
Group-login birthday add-on createKeep the create action available. Resolve the selected top-level restaurant_id; when blank, use the staff default restaurant.

7. Test strategy (FE-side, v1)

Per grill-with-docs FE round 2:

  • Component tests (Cypress component, already configured in book-bite/package.json: "test:unit:ci"): cover Form validation (including prepayment with zero/one/multiple payment types and time-in-advance values), Items Included repeater (3 initial rows, Cake requirement, minimum-one-row guard), mutual-exclusivity UX (Flow 9), free-treat mode UI visibility, image-uploader error states.
  • E2E tests (Cypress + Playwright): create-edit-delete flow happy path; group-login mixed-restaurant list/filter and default-restaurant create path; daily-limit edge case (backend sends error, FE surfaces it).
  • Visual regression (if book-bite has it set up): Birthday add-on list, Birthday add-on form (create, edit, with-price-zero, with-image).

Test mocks: use the existing Factory / VCR-style pattern in book-bite. Stub backend at the API boundary, not at the network level.


8. Open questions for FE team when v1 implementation starts

These are the only branches worth re-grilling once the implementation begins:

  • Existing image-uploader: confirm exact props / events before reusing in ImageUploaderMenu.vue (since menu image is new).
  • Repeated list behavior: confirm the book-bite repeater pattern for 3 initial rows with add/remove and a minimum-one-row guard (per Find_Repeater, if available).
  • Mutual exclusivity disabling styling: does book-bite have an existing disabled / dimmed pattern for list items, or do we need a new one?

Everything else in this PRD has been resolved — implementation should not require re-grilling unless a hidden implementation constraint surfaces.