Reverse PRD — Package Detail Page
Scope: packages/pages/src/package/ (13 components) + route apps/ionic/src/pages/[lang]/restaurants/[slug]/package/[id]/index.vue
Evidence base: Source code read on 2026-05-12 post-migration from hh-pegasus (Astro SSR) → hh-pegasus-monorepo (Vue CSR). Includes post-migration bug fixes from PR #206 review (commits 2860d7b, a025c7a).
Features Found
- Package Detail Page — data loading and routing
- Package Toolbar — navigation and sharing
- Package Content Display — body, pricing, benefits
- Menu Viewing — multi-type menu modal
- Quantity Management — add/remove package pax
- Package Comparison — comparison table modal
- Booking Flow — mobile sticky bar + desktop sidebar
- Analytics & Tracking — Netcore event emission
1. Package Detail Page — Entry Point & Data Loading
1.1 Objective & Context
Renders the full detail view of a single restaurant package. Acts as the CSR entry point that fetches all page data client-side (package card props, restaurant info, pack instance) and orchestrates composition of toolbar, content, and booking panels.
Route: /[lang]/restaurants/[slug]/package/[id]
1.2 User Persona & Access Control
| Persona | Access Level | Evidence |
|---|---|---|
| Guest (unauthenticated) | Full read access | PackagePage.vue — no auth guard, page loads unconditionally |
| Authenticated user | Can book | Booking stores activated after data load (bookingPackageStore, bookingStore) |
1.3 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | As a user, I can navigate to a package detail URL and see the package loaded | PackagePage.vue:105-135 — loadPageData() calls preparePackagePage({ packageId, restaurantSlug, lang }) on mount |
| FR-2 | As a user, I am redirected if the package has expired | PackagePage.vue:113-116 — if (result.redirect && typeof result.data === "string") { window.location.href = result.data } |
| FR-3 | As a user, I see a loading spinner while data fetches | PackagePage.vue:38-40 — <div v-else> renders spinning ring while !isDataLoaded |
| FR-4 | As a user, I see an error toast if data loading fails | PackagePage.vue:119,133 — toast.error(...) on both non-success result and catch |
| FR-5 | As a user, removing a package from the cart redirects me to the restaurant page | PackagePage.vue:137-139 — onRemovePackage() sets window.location.href = restaurantPageUrl.value |
1.4 Business Logic
-
Rule:
preparePackagePageresult withredirect: trueand stringdatatriggers a hard navigation away from the page.- Intent: Expired packages should not be viewable; redirect user to an appropriate alternative.
- Code:
PackagePage.vue:113-116
-
Rule:
restaurantPackagesStore.packageToView = data.packagesis set immediately after fetch beforeisDataLoaded = true.- Intent: Child components read
packageToViewfrom the store; it must be populated before they mount. - Code:
PackagePage.vue:125
- Intent: Child components read
-
Rule: Desktop/mobile layout split at
min-width: 1024pxviauseMediaQuery.- Intent: Desktop shows
BookingPageDesktopin a right-side column (4/12 width); mobile showsBookingPageMobileas a fixed bottom bar. - Code:
PackagePage.vue:80-81, 31-36
- Intent: Desktop shows
1.5 Technical Constraints
- Tech Stack: Vue 3 + Vite + Pinia +
@vueuse/core(useMediaQuery) - API:
preparePackagePagefrom@hh-pegasus/shared-service/package/packageDetail— fetches package card and pack instance in one call - Lazy loading:
BookingPageDesktoploaded viadefineAsyncComponent— not bundled in initial chunk - No SSR: All data fetching in
onMounted; page renders blank (spinner) until fetch completes
1.6 Gap Analysis
restaurantDetailStore.countryCodepassed toPackageToolbarasrestaurant.countryCodebut the store may not populate it frompreparePackagePage— toolbar receives an empty string if not set.— Fixed in PR #206:cityNamehardcoded as''CITY_NAME_BANGKOKconstant now passed toPackageToolbar.
2. Package Toolbar — Navigation & Sharing
2.1 Objective & Context
Renders the top navigation bar for the package page. On desktop shows a breadcrumb + minimal header; on mobile shows a sticky header that reacts to scroll position with a share button.
2.2 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | As a user, I can tap Back to return to the restaurant page | PackageToolbar.vue:94-103 — onBackClicked() sets window.location.href = props.restaurant.link |
| FR-2 | As a user on mobile, I can share the package via Web Share API | PackageToolbar.vue:106-122 — navigator.share({ title, text, url }) called when available |
| FR-3 | As a user on mobile without Web Share API support, I see a social modal | PackageToolbar.vue:118-122 — fallback sets showShareModal = true, renders ShareSosmedModal |
| FR-4 | As a user on desktop, I see a breadcrumb trail | PackageToolbar.vue:18 — <PackageBreadCrumb v-if="props.isDesktop" /> |
| FR-5 | On mobile, the toolbar changes style after 40px scroll | PackageToolbar.vue:85-92 — checkScroll() sets isScrolled when scrollY > 40 |
2.3 Business Logic
-
Rule:
loadOpeningShareModalflag prevents double-opening the share modal.- Code:
PackageToolbar.vue:108—if (!loadOpeningShareModal.value)
- Code:
-
Rule:
toggleFullPageLoader(true/false)wraps the share flow to prevent user interaction during async share.- Code:
PackageToolbar.vue:111, 113, 122
- Code:
2.4 Gap Analysis
titleTextprop is accepted butPackageToolbarDesktopmay not render a title when empty — no fallback text defined in toolbar desktop component.Scroll listener never removed— Fixed in PR #206:onUnmountedcleanup added toPackageToolbar.vue.
3. Package Content Display — Body & Pricing
3.1 Objective & Context
PackageDetailView orchestrates the full content area. It mounts PackageDetailBody with restaurant metadata, then loads modals for media viewing and booking confirmations. PackageDetailBody renders package type badge, name, pricing, quantity controls, and benefit details.
3.2 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | As a user, I see the package name, type, and price | PackageDetailBody.vue:28-67 — PackageTypeText, name div, PackagePricing rendered |
| FR-2 | As a user, I see the corporate price hidden when isCorporateBooking | PackageDetailBody.vue:29 — v-show="!isCorporateBooking" on price section |
| FR-3 | As a user with PointX active, I see loyalty point display alongside price | PackageDetailBody.vue:45-48 — PointXDisplay shown when partnerIntegration?.isPointXActive |
| FR-4 | As a user, the quantity shown reflects my cart (not just the initial prop) | PackageDetailView.vue:189-194 — quantity computed returns packageToView.attributes.quantity after isDataLoadedClient, otherwise packageProps.quantity |
| FR-5 | As a user, restaurantInformation fields come from packageProps | PackageDetailView.vue:203-213 — all fields (isNewRestaurant, diningStyle, cuisine, etc.) sourced from packageProps.* not store |
3.3 Business Logic
-
Rule:
enableDynamicPricingV2flag from GrowthBook feature"enable-dynamic-pricing-v2"gates whether dynamic pricing V2 data is fetched.- Intent: Rollout control for the new pricing engine.
- Code:
PackageDetailView.vue:664-683— defaulttrueif GrowthBook call fails - Edge case: DIY packages skip pricing rules fetch entirely:
PackageDetailView.vue:491-493
-
Rule:
getPackagePricingRulescalled withpriceEngineDomainfromVITE_API_PRICE_ENGINE_DOMAINenv var.- Intent: Pricing engine lives on a separate domain from the main API.
- Code:
PackageDetailView.vue:503-508
-
Rule: After fetching package detail,
packageToView.attributes.isSoldOut = falseis force-set.- Intent: Sold-out packages are presumably blocked at the package list level; viewing the detail page implies availability.
- Code:
PackageDetailView.vue:457
3.4 Technical Constraints
- GrowthBook: Initialized on every mount via
createGrowthBookInstance; no caching between navigations. isRwgBookingstubbed:computed(() => false)— Reserve With Google integration removed;onBackToRestaurantalways redirects.- Code:
PackageDetailView.vue:143
- Code:
3.5 Gap Analysis
PackageDetailBodytype errors (pre-existing):durationTypeandperiodRoundsfields expected by the component are missing fromPackageCardDetailPropsin the monoreposhared-typepackage. Component renders but TypeScript compilation fails.restaurantIcontype mismatch:packageProps.restaurantIconpassed toPackageDetailBodybut type compatibility unverified.PopupGallerymissing: Original code had a gallery popup engine. Monorepo version always callshhModal.open("package-media-modal")regardless ofmenuPopupEnginevalue. Gallery experience is degraded to a modal-only flow.
3.6 Post-Review Fixes Applied (2026-05-12)
| Fix | File | Commit |
|---|---|---|
isLoading=false in catch block → skeleton no longer stuck on fetch error | PackageMenuList.vue:91 | 2860d7b |
hh_menu branch respects props.isAddOn (was always reading packageToView) | QrMenuImage.vue:209 | 2860d7b |
specialDayPrices / specialDayKidPrices now populated during rule iteration | DynamicPricing.vue:130-135 | a025c7a |
setInterval cleared in onUnmounted (was leaking if component unmounts early) | DynamicPricing.vue:247-249 | 2860d7b |
maxPrice normalized via Number() before comparison (prop accepts string|number) | DynamicPricingItemPrice.vue:50 | 2860d7b |
isCorrectExtension strips ?query#hash before extension check | shared-helper/file.ts:5 | 2860d7b |
4. Menu Viewing
4.1 Objective & Context
Allows users to view the full package menu. Supports four menu content types, all rendered inside a shared HhModal (id="package-media-modal").
4.2 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | As a user, clicking “View Menu” opens a modal | PackageDetailView.vue:660-662 — openPopup() calls hhModal.open("package-media-modal") |
| FR-2 | As a user, V2 menus render as an iframe | PackageDetailView.vue:536-544 — menu_v2 type fetches HTML preview URL and sets packageMediaLink; renders as <iframe> at PackageDetailView.vue:44-51 |
| FR-3 | As a user, image menus render as HhImage gallery | PackageDetailView.vue:545-554 — image/img type extracts menus.data.img[*].original URLs |
| FR-4 | As a user, iMenuPro menus render as an iframe | PackageDetailView.vue:555-561 — imenupro type takes first link from menus.data.imenupro[0].link |
| FR-5 | As a user, QR menu items render via QrMenuImage | packages/pages/src/package/ui/QrMenuImage.vue — separate component (not inline in modal) |
| FR-6 | As a user, if menu fails to load I see a Retry button | PackageDetailView.vue:62-68 — failedLoadMenu ref shows retry button |
| FR-7 | As a user, clicking a menu item image opens MenuImageData modal | PackageDetailView.vue:602-614 — onViewMenuImageHandler calls getMenuImageData(selectedPackages, packageId, sectionId, menuId) |
| FR-8 | As a user, clicking “View Full Menu” navigates to menu page | PackageDetailView.vue:616-635 — window.location.href = viewMenuPackagePage(lang, slug, packageId) |
4.3 Business Logic
-
Rule:
menu_v2type fetches the HTML preview via a plainfetch()call and inlines the response as iframesrcdoc-equivalent. Failed fetch setsfailedLoadMenu = true.- Code:
PackageDetailView.vue:564-591
- Code:
-
Rule:
menuPopupEngineis set per menu type but is no longer used to branch popup behavior —openPopup()always opens the modal.- Intent: Was previously used to choose between a gallery and a popup component. Now dead state.
- Code:
PackageDetailView.vue:543, 553, 560, 660-662
4.4 Gap Analysis
menuPopupEngineis dead state: Written but never read for branching.openPopup()ignores it.- Multiple menu types can be simultaneously active:
setPackageMedia()usesifnotelse if, sopackageMediaLinkandpackageMediaImgcould both be populated. Rendering order in modal is deterministic (links first, images second), but this may not be intentional.
5. Quantity Management
5.1 Objective & Context
Users can increase or decrease the number of pax (quantity) for the package directly from the detail page. Decrease to zero triggers a remove-package confirmation modal.
5.2 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | As a user, I can increase the package quantity | PackageDetailView.vue:215-238 — calls increasePackQuantityHandler, sets booking priority to "select-package" |
| FR-2 | As a user, I can decrease the package quantity | PackageDetailView.vue:241-255 — calls decreasePackQuantityHandler |
| FR-3 | As a user, decreasing to zero shows a confirmation modal | PackageDetailView.vue:307-342 — togglePopUpDeletePackage() opens "remove-package" modal via toggleModal |
| FR-4 | Confirming removal clears the package from cart and resets date/time | PackageDetailView.vue:323-334 — removeSelectedPackages, removeSeletedPackagesFromStorage, reset quantity, bookingStore.resetSelectedDateTime() |
| FR-5 | If party size is too big/small, a dedicated modal appears | PackageDetailView.vue:289-293 — toggleModal({ type: "party-too-big" }) / "party-too-small" |
5.3 Business Logic
-
Rule: Quantity is synced from
bookingPackageStore.selectedPackagesvia two watchers — one onselectedPackagesFromClient(immediate + deep) and one onselectedPackages.- Intent: Cart changes from other components (e.g. booking modals) must reflect in the displayed quantity.
- Code:
PackageDetailView.vue:716-732
-
Rule:
modal.confirmedis set totruebefore removal operations to satisfy thedecreasePackQuantityHandlerconfirmation signal.- Code:
PackageDetailView.vue:323
- Code:
6. Package Comparison
6.1 Objective & Context
Shows a comparison table of packages within the same menu group. Data is fetched as part of fetchDetailPackage() and stored on packageToView.attributes.packageComparison.
6.2 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | As a user, I can see package comparison if the package belongs to a menu group | PackageDetailView.vue:463-474 — fetchPackageComparisonData called only when menuGroupId is set |
| FR-2 | Comparison data is scoped by menu group, restaurant, package type, and current package | PackageDetailView.vue:466-473 — all four params passed to fetchPackageComparisonData |
6.3 Gap Analysis
PackageComparison.vuetype errors (pre-existing): Renders insidePackageDetailBody— any prop mismatches propagate fromPackageCardDetailPropsshape.
7. Booking Flow
7.1 Objective & Context
- Mobile: Fixed bottom bar (
BookingPageMobile) visible when packages or tickets are selected. Tapping it opensBookingPageMobilePopup. - Desktop: Sidebar panel (
BookingPageDesktop) rendered in the right column, always visible.
Both trigger bookingChargeStore.calculateCharge() when DIY package selections change.
7.2 Functional Requirements
| # | User Story | Code Evidence |
|---|---|---|
| FR-1 | Mobile bar only shows when packages or tickets are selected | BookingPageMobile.vue:4-6 — v-if="selectedPackages.length > 0 || selectedPackageTickets.length > 0" |
| FR-2 | Mobile bar shows price only for DIY with complete selection | BookingPageMobile.vue:41-48 — isShowPrice computed on hasSelectedDIYPackage && !isRequiredCalculatePriceForDIY |
| FR-3 | DIY price is recalculated throttled at 700ms | BookingPageMobile.vue:51-53 — throttle(fetchCalculatePackage, 700, { leading: false }) |
| FR-4 | Desktop booking panel loaded lazily | PackagePage.vue:65-67 — defineAsyncComponent(() => import("@pages/booking/BookingPageDesktop.vue")) |
| FR-5 | Booking modals (remove-package, party-size) rendered conditionally by viewport | PackageDetailView.vue:74-83 — BookingModalsDesktop when isDesktop, BookingModalsMobile otherwise |
| FR-6 | On mount, desktop auto-triggers availability check if adult + date/time already set | PackageDetailView.vue:365-373 — onMountedCallback() calls setPackagesAvailability(lang) |
| FR-7 | DTP (date/time/pax) from restaurantDtpStore is applied to booking store on mount | PackageDetailView.vue:344-362 — setDtpFromStore() copies adult, kids, date, time |
7.3 Business Logic
- Rule:
bookingStore.ui.isInPackagePage = trueset on mount;isLoadingCheckout = trueduring fetch, thenfalseafter.- Intent: Booking UI knows it is in package context (vs restaurant detail), enabling/disabling specific flows.
- Code:
PackageDetailView.vue:697-698, 712
7.4 Gap Analysis
BookingPageMobile.vuepre-existing build error:@hh-pegasus/shared-store/packageTicketimport path not found in package exports. Component will fail at runtime.BookingPageDesktopnot in this slice: Loaded from@pages/booking/BookingPageDesktop.vue— maintained in the booking slice, not the package slice.
8. Analytics & Tracking
8.1 Objective & Context
Two analytics platforms are instrumented: Netcore (primary) with a package-viewed event. No page-level Clevertap call found in the package slice (Clevertap calls likely exist in booking sub-flows).
8.2 Success Metrics
| Event / Key | Trigger | Payload Fields | Inferred Intent |
|---|---|---|---|
TRACK_EVENT_PACKAGE_VIEWED | End of onMounted, after all data loaded | { id, name, restaurant_id, city_name: "Bangkok" } | Track package detail page views for analytics |
Code: usePackageAnalytics.ts:15-25 — sendNetcoreEvent(TRACK_EVENT_PACKAGE_VIEWED, { id, name, restaurant_id, city_name: CITY_NAME_BANGKOK })
8.3 Gap Analysis
- No error tracking for analytics failure:
sendEventNetcorePackageViewedcatches errors withhandleErrorbut does not surface to the user — silently fails. - No page-level Clevertap event:
sendEventClevertapSelectPeopleexists in shared-service but is not called from this slice. ⚠️ Requires Stakeholder Clarification — intentional omission or regression? - Analytics fires even on partial load failure: If
fetchRestaurantDetailfails butfetchDetailPackagesucceeds (both run inPromise.all), the event still fires with a potentially emptyrestaurant_id.
Review Checkpoint
Features Found (8)
- Package Detail Page — entry point, data loading, expired-package redirect
- Package Toolbar — back navigation, Web Share API, scroll-aware mobile header
- Package Content Display — body, pricing engine (V1/V2), GrowthBook flag, PointX
- Menu Viewing — V2 iframe, image gallery, iMenuPro, QR menu, full-menu navigation
- Quantity Management — increase/decrease, remove-package modal, party-size modals
- Package Comparison — menu-group-scoped comparison table
- Booking Flow — mobile sticky bar + desktop sidebar + DTP pre-population
- Analytics — Netcore
TRACK_EVENT_PACKAGE_VIEWEDon mount
Largest Gaps (current, post-review)
BookingPageMobileimport error (@hh-pegasus/shared-store/packageTicketunresolvable) — will cause runtime failure on mobile booking flow.PackageDetailBodymissing props (durationType,periodRounds) — TypeScript errors; fields present in source but not in monorepoPackageCardDetailPropstype.menuPopupEnginedead state — Set per menu type but never read byopenPopup(); gallery experience replaced by modal-only with no branching.
Resolved Since Initial PRD
Scroll listener leak in→ Fixed (onUnmounted cleanup)PackageToolbar→ Fixed (CITY_NAME_BANGKOK)cityNamehardcoded as''→ FixedisLoadingstuck on menu fetch error→ Fixed (arrays now populated)specialDayPricingalways 0→ FixedsetIntervalleak inDynamicPricing→ Fixedhh_menuignoresisAddOn→ FixedisCorrectExtensionbreaks on query params
Clarifications Needed
- ⚠️ Is the absence of a Clevertap page-view event intentional, or was it lost during migration?
- ⚠️
menuPopupEngineis set but never used — should gallery mode be restored, or is modal-only the intended behavior post-migration?