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)).
| Owner | Frontend (book-bite: restaurant-facing; hh-pegasus: diner-facing) |
| Backend PRD | 2002_Birthday_AddOn_20260716170000.md |
| Backend engineering spec | 2026-07-30-birthday-add-on-hh-server.md |
| Book-Bite API / FE plan | 2026-08-06-birthday-add-on-book-bite-api.md and 2026-08-06-birthday-add-on-api-contract.md |
| Glossary | 📕 Glossary in PM PRD |
| Status | Draft — 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 Includedrepeatable list with 3 initial rows, at least oneCakerow, free-vs-paid toggle via price=0, image-upload rules + new dimensions). - Validation rules diverge from regular add-ons (price
0is meaningful; at least one activeCakerow 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_typeeverywhere, making futureadd_on_typevalues (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
| Component | Reuse from existing? |
|---|---|
| Image upload widget | Reuse 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 display | Reuse existing inline-error patterns; consider the same FormValidationSummary if existing add-on form has one. |
| Card display | Reuse existing add-on card component if it takes arbitrary add_on props (since the data shape is identical). |
| Repeater for items-included | New component (3 initial rows, with add/remove and minimum-one-row guard). |
| Date-range / image-upload rule from existing add-on form | May 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.
| Field | Rule (UX-side) | Error message key |
|---|---|---|
| Cover image | Required for create, optional for edit. Max 10MB. Recommended 420×240px. JPG, PNG, TIFF, HEIC, WEBP. | birthdayAddOn.errors.coverImage |
| Menu image | Optional. Max 10MB. Recommended 2048×2048px. Same formats. | birthdayAddOn.errors.menuImage |
| Price | Required. Zero allowed. Decimals allowed. | birthdayAddOn.errors.priceRequired / priceInvalid |
| Time in advance | Required (defaults to “Follow Restaurant Level”). | birthdayAddOn.errors.timeInAdvanceRequired |
| Require prepayment | Optional checkbox; defaults to off. When enabled, at least one payment type is required. | birthdayAddOn.errors.paymentTypeRequired |
| Payment types | Multi-select, populated from GET /restaurant_packages/payment_types; multiple types are allowed. | birthdayAddOn.errors.paymentTypeRequired |
| Tag | Optional, autocomplete from existing tag list. | (no error typically) |
| Items Included | Starts 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&C | Optional image. | (no error) |
| Restaurant scope | Single 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 state | API key | Contract |
|---|---|---|
requirePrepayment | add_on[require_prepayment] | Boolean, default false. |
paymentTypes | add_on[add_on_payment_types_attributes] | Array of selected integer payment_type_id values; multiple selections are supported. |
minBookingTimeInAdvance | add_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
| Event | When fired | Payload (standard + context) |
|---|---|---|
birthday_addon_created | Restaurant 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
| Event | Payload notes |
|---|---|
birthday_icon_impression | restaurant_id, add_on_id (or array, if restaurant has multiple add-ons), position (where on card) |
birthday_icon_click | restaurant_id, add_on_id, position, source_card (‘homepage’ | ‘search’ | ‘group-landing-page’ | ‘bite-list’) |
birthday_section_view | restaurant_id, scroll_position, source (deeplink query param vs scroll) |
birthday_add_to_cart | restaurant_id, add_on_id, quantity, price_paid, is_free |
birthday_occasion_selected | restaurant_id, occasion (= ‘birthday’, but track string explicitly) |
birthday_free_addon_added | restaurant_id, add_on_id, price_paid (=0), trigger (‘occasion_selected’) |
birthday_checkout_success | restaurant_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).
| Key | EN | TH | CN |
|---|---|---|---|
birthdayAddOn.title | Birthday Add-On | (TBD by Localization) | (TBD by Localization) |
birthdayAddOn.listHeading | Birthday Celebration | — | — |
birthdayAddOn.actions.new | New Birthday Add-On | — | — |
birthdayAddOn.actions.edit | Edit Birthday Add-On | — | — |
birthdayAddOn.actions.delete | Delete | — | — |
birthdayAddOn.actions.delete.confirmTitle | Delete this birthday add-on? | — | — |
birthdayAddOn.actions.delete.confirmBody | This add-on will be removed from the restaurant page immediately. This cannot be undone. | — | — |
birthdayAddOn.form.cakeRow | Cake (required) | — | — |
birthdayAddOn.freeTreat.helperText | This add-on will be offered as a complimentary treat. Diner will see it only after selecting ‘Birthday’ as their occasion at checkout. | — | — |
birthdayAddOn.mutualExclusivity.tooltip | You can only choose one birthday add-on per booking. | — | — |
birthdayAddOn.errors.groupLoginBlocked | Legacy error retained for compatibility; group-login status does not block birthday add-on access. | — | — |
birthdayAddOn.errors.priceRequired | Price is required. | — | — |
birthdayAddOn.errors.cakeRequired | The 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 case | Handling |
|---|---|
| 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 delay | Icon 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-form | If 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 / failure | Show inline retry on the image preview block; do not block form submit (cover image optional on edit, menu image optional). |
| Form dirty + browser back/close | Show beforeunload warning when dirty (PM PRD: “If there are unsaved changes, prompt the user before exiting”). |
| Stale data after rapid re-save | Submit 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 create | Keep 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/dimmedpattern 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.