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

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

  1. Package Detail Page — data loading and routing
  2. Package Toolbar — navigation and sharing
  3. Package Content Display — body, pricing, benefits
  4. Menu Viewing — multi-type menu modal
  5. Quantity Management — add/remove package pax
  6. Package Comparison — comparison table modal
  7. Booking Flow — mobile sticky bar + desktop sidebar
  8. 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

PersonaAccess LevelEvidence
Guest (unauthenticated)Full read accessPackagePage.vue — no auth guard, page loads unconditionally
Authenticated userCan bookBooking stores activated after data load (bookingPackageStore, bookingStore)

1.3 Functional Requirements

#User StoryCode Evidence
FR-1As a user, I can navigate to a package detail URL and see the package loadedPackagePage.vue:105-135 — loadPageData() calls preparePackagePage({ packageId, restaurantSlug, lang }) on mount
FR-2As a user, I am redirected if the package has expiredPackagePage.vue:113-116 — if (result.redirect && typeof result.data === "string") { window.location.href = result.data }
FR-3As a user, I see a loading spinner while data fetchesPackagePage.vue:38-40 — <div v-else> renders spinning ring while !isDataLoaded
FR-4As a user, I see an error toast if data loading failsPackagePage.vue:119,133 — toast.error(...) on both non-success result and catch
FR-5As a user, removing a package from the cart redirects me to the restaurant pagePackagePage.vue:137-139 — onRemovePackage() sets window.location.href = restaurantPageUrl.value

1.4 Business Logic

  • Rule: preparePackagePage result with redirect: true and string data triggers 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.packages is set immediately after fetch before isDataLoaded = true.

    • Intent: Child components read packageToView from the store; it must be populated before they mount.
    • Code: PackagePage.vue:125
  • Rule: Desktop/mobile layout split at min-width: 1024px via useMediaQuery.

    • Intent: Desktop shows BookingPageDesktop in a right-side column (4/12 width); mobile shows BookingPageMobile as a fixed bottom bar.
    • Code: PackagePage.vue:80-81, 31-36

1.5 Technical Constraints

  • Tech Stack: Vue 3 + Vite + Pinia + @vueuse/core (useMediaQuery)
  • API: preparePackagePage from @hh-pegasus/shared-service/package/packageDetail — fetches package card and pack instance in one call
  • Lazy loading: BookingPageDesktop loaded via defineAsyncComponent — not bundled in initial chunk
  • No SSR: All data fetching in onMounted; page renders blank (spinner) until fetch completes

1.6 Gap Analysis

  • restaurantDetailStore.countryCode passed to PackageToolbar as restaurant.countryCode but the store may not populate it from preparePackagePage — toolbar receives an empty string if not set.
  • cityName hardcoded as '' — Fixed in PR #206: CITY_NAME_BANGKOK constant now passed to PackageToolbar.

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 StoryCode Evidence
FR-1As a user, I can tap Back to return to the restaurant pagePackageToolbar.vue:94-103 — onBackClicked() sets window.location.href = props.restaurant.link
FR-2As a user on mobile, I can share the package via Web Share APIPackageToolbar.vue:106-122 — navigator.share({ title, text, url }) called when available
FR-3As a user on mobile without Web Share API support, I see a social modalPackageToolbar.vue:118-122 — fallback sets showShareModal = true, renders ShareSosmedModal
FR-4As a user on desktop, I see a breadcrumb trailPackageToolbar.vue:18 — <PackageBreadCrumb v-if="props.isDesktop" />
FR-5On mobile, the toolbar changes style after 40px scrollPackageToolbar.vue:85-92 — checkScroll() sets isScrolled when scrollY > 40

2.3 Business Logic

  • Rule: loadOpeningShareModal flag prevents double-opening the share modal.

    • Code: PackageToolbar.vue:108 — if (!loadOpeningShareModal.value)
  • Rule: toggleFullPageLoader(true/false) wraps the share flow to prevent user interaction during async share.

    • Code: PackageToolbar.vue:111, 113, 122

2.4 Gap Analysis

  • titleText prop is accepted but PackageToolbarDesktop may not render a title when empty — no fallback text defined in toolbar desktop component.
  • Scroll listener never removed — Fixed in PR #206: onUnmounted cleanup added to PackageToolbar.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 StoryCode Evidence
FR-1As a user, I see the package name, type, and pricePackageDetailBody.vue:28-67 — PackageTypeText, name div, PackagePricing rendered
FR-2As a user, I see the corporate price hidden when isCorporateBookingPackageDetailBody.vue:29 — v-show="!isCorporateBooking" on price section
FR-3As a user with PointX active, I see loyalty point display alongside pricePackageDetailBody.vue:45-48 — PointXDisplay shown when partnerIntegration?.isPointXActive
FR-4As 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-5As a user, restaurantInformation fields come from packagePropsPackageDetailView.vue:203-213 — all fields (isNewRestaurant, diningStyle, cuisine, etc.) sourced from packageProps.* not store

3.3 Business Logic

  • Rule: enableDynamicPricingV2 flag 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 — default true if GrowthBook call fails
    • Edge case: DIY packages skip pricing rules fetch entirely: PackageDetailView.vue:491-493
  • Rule: getPackagePricingRules called with priceEngineDomain from VITE_API_PRICE_ENGINE_DOMAIN env 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 = false is 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.
  • isRwgBooking stubbed: computed(() => false) — Reserve With Google integration removed; onBackToRestaurant always redirects.
    • Code: PackageDetailView.vue:143

3.5 Gap Analysis

  • PackageDetailBody type errors (pre-existing): durationType and periodRounds fields expected by the component are missing from PackageCardDetailProps in the monorepo shared-type package. Component renders but TypeScript compilation fails.
  • restaurantIcon type mismatch: packageProps.restaurantIcon passed to PackageDetailBody but type compatibility unverified.
  • PopupGallery missing: Original code had a gallery popup engine. Monorepo version always calls hhModal.open("package-media-modal") regardless of menuPopupEngine value. Gallery experience is degraded to a modal-only flow.

3.6 Post-Review Fixes Applied (2026-05-12)

FixFileCommit
isLoading=false in catch block → skeleton no longer stuck on fetch errorPackageMenuList.vue:912860d7b
hh_menu branch respects props.isAddOn (was always reading packageToView)QrMenuImage.vue:2092860d7b
specialDayPrices / specialDayKidPrices now populated during rule iterationDynamicPricing.vue:130-135a025c7a
setInterval cleared in onUnmounted (was leaking if component unmounts early)DynamicPricing.vue:247-2492860d7b
maxPrice normalized via Number() before comparison (prop accepts string|number)DynamicPricingItemPrice.vue:502860d7b
isCorrectExtension strips ?query#hash before extension checkshared-helper/file.ts:52860d7b

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 StoryCode Evidence
FR-1As a user, clicking “View Menu” opens a modalPackageDetailView.vue:660-662 — openPopup() calls hhModal.open("package-media-modal")
FR-2As a user, V2 menus render as an iframePackageDetailView.vue:536-544 — menu_v2 type fetches HTML preview URL and sets packageMediaLink; renders as <iframe> at PackageDetailView.vue:44-51
FR-3As a user, image menus render as HhImage galleryPackageDetailView.vue:545-554 — image/img type extracts menus.data.img[*].original URLs
FR-4As a user, iMenuPro menus render as an iframePackageDetailView.vue:555-561 — imenupro type takes first link from menus.data.imenupro[0].link
FR-5As a user, QR menu items render via QrMenuImagepackages/pages/src/package/ui/QrMenuImage.vue — separate component (not inline in modal)
FR-6As a user, if menu fails to load I see a Retry buttonPackageDetailView.vue:62-68 — failedLoadMenu ref shows retry button
FR-7As a user, clicking a menu item image opens MenuImageData modalPackageDetailView.vue:602-614 — onViewMenuImageHandler calls getMenuImageData(selectedPackages, packageId, sectionId, menuId)
FR-8As a user, clicking “View Full Menu” navigates to menu pagePackageDetailView.vue:616-635 — window.location.href = viewMenuPackagePage(lang, slug, packageId)

4.3 Business Logic

  • Rule: menu_v2 type fetches the HTML preview via a plain fetch() call and inlines the response as iframe srcdoc-equivalent. Failed fetch sets failedLoadMenu = true.

    • Code: PackageDetailView.vue:564-591
  • Rule: menuPopupEngine is 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

  • menuPopupEngine is dead state: Written but never read for branching. openPopup() ignores it.
  • Multiple menu types can be simultaneously active: setPackageMedia() uses if not else if, so packageMediaLink and packageMediaImg could 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 StoryCode Evidence
FR-1As a user, I can increase the package quantityPackageDetailView.vue:215-238 — calls increasePackQuantityHandler, sets booking priority to "select-package"
FR-2As a user, I can decrease the package quantityPackageDetailView.vue:241-255 — calls decreasePackQuantityHandler
FR-3As a user, decreasing to zero shows a confirmation modalPackageDetailView.vue:307-342 — togglePopUpDeletePackage() opens "remove-package" modal via toggleModal
FR-4Confirming removal clears the package from cart and resets date/timePackageDetailView.vue:323-334 — removeSelectedPackages, removeSeletedPackagesFromStorage, reset quantity, bookingStore.resetSelectedDateTime()
FR-5If party size is too big/small, a dedicated modal appearsPackageDetailView.vue:289-293 — toggleModal({ type: "party-too-big" }) / "party-too-small"

5.3 Business Logic

  • Rule: Quantity is synced from bookingPackageStore.selectedPackages via two watchers — one on selectedPackagesFromClient (immediate + deep) and one on selectedPackages.

    • Intent: Cart changes from other components (e.g. booking modals) must reflect in the displayed quantity.
    • Code: PackageDetailView.vue:716-732
  • Rule: modal.confirmed is set to true before removal operations to satisfy the decreasePackQuantityHandler confirmation signal.

    • Code: PackageDetailView.vue:323

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 StoryCode Evidence
FR-1As a user, I can see package comparison if the package belongs to a menu groupPackageDetailView.vue:463-474 — fetchPackageComparisonData called only when menuGroupId is set
FR-2Comparison data is scoped by menu group, restaurant, package type, and current packagePackageDetailView.vue:466-473 — all four params passed to fetchPackageComparisonData

6.3 Gap Analysis

  • PackageComparison.vue type errors (pre-existing): Renders inside PackageDetailBody — any prop mismatches propagate from PackageCardDetailProps shape.

7. Booking Flow

7.1 Objective & Context

  • Mobile: Fixed bottom bar (BookingPageMobile) visible when packages or tickets are selected. Tapping it opens BookingPageMobilePopup.
  • 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 StoryCode Evidence
FR-1Mobile bar only shows when packages or tickets are selectedBookingPageMobile.vue:4-6 — v-if="selectedPackages.length > 0 || selectedPackageTickets.length > 0"
FR-2Mobile bar shows price only for DIY with complete selectionBookingPageMobile.vue:41-48 — isShowPrice computed on hasSelectedDIYPackage && !isRequiredCalculatePriceForDIY
FR-3DIY price is recalculated throttled at 700msBookingPageMobile.vue:51-53 — throttle(fetchCalculatePackage, 700, { leading: false })
FR-4Desktop booking panel loaded lazilyPackagePage.vue:65-67 — defineAsyncComponent(() => import("@pages/booking/BookingPageDesktop.vue"))
FR-5Booking modals (remove-package, party-size) rendered conditionally by viewportPackageDetailView.vue:74-83 — BookingModalsDesktop when isDesktop, BookingModalsMobile otherwise
FR-6On mount, desktop auto-triggers availability check if adult + date/time already setPackageDetailView.vue:365-373 — onMountedCallback() calls setPackagesAvailability(lang)
FR-7DTP (date/time/pax) from restaurantDtpStore is applied to booking store on mountPackageDetailView.vue:344-362 — setDtpFromStore() copies adult, kids, date, time

7.3 Business Logic

  • Rule: bookingStore.ui.isInPackagePage = true set on mount; isLoadingCheckout = true during fetch, then false after.
    • 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.vue pre-existing build error: @hh-pegasus/shared-store/packageTicket import path not found in package exports. Component will fail at runtime.
  • BookingPageDesktop not 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 / KeyTriggerPayload FieldsInferred Intent
TRACK_EVENT_PACKAGE_VIEWEDEnd 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: sendEventNetcorePackageViewed catches errors with handleError but does not surface to the user — silently fails.
  • No page-level Clevertap event: sendEventClevertapSelectPeople exists 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 fetchRestaurantDetail fails but fetchDetailPackage succeeds (both run in Promise.all), the event still fires with a potentially empty restaurant_id.

Review Checkpoint

Features Found (8)

  1. Package Detail Page — entry point, data loading, expired-package redirect
  2. Package Toolbar — back navigation, Web Share API, scroll-aware mobile header
  3. Package Content Display — body, pricing engine (V1/V2), GrowthBook flag, PointX
  4. Menu Viewing — V2 iframe, image gallery, iMenuPro, QR menu, full-menu navigation
  5. Quantity Management — increase/decrease, remove-package modal, party-size modals
  6. Package Comparison — menu-group-scoped comparison table
  7. Booking Flow — mobile sticky bar + desktop sidebar + DTP pre-population
  8. Analytics — Netcore TRACK_EVENT_PACKAGE_VIEWED on mount

Largest Gaps (current, post-review)

  1. BookingPageMobile import error (@hh-pegasus/shared-store/packageTicket unresolvable) — will cause runtime failure on mobile booking flow.
  2. PackageDetailBody missing props (durationType, periodRounds) — TypeScript errors; fields present in source but not in monorepo PackageCardDetailProps type.
  3. menuPopupEngine dead state — Set per menu type but never read by openPopup(); gallery experience replaced by modal-only with no branching.

Resolved Since Initial PRD

  • Scroll listener leak in PackageToolbar → Fixed (onUnmounted cleanup)
  • cityName hardcoded as '' → Fixed (CITY_NAME_BANGKOK)
  • isLoading stuck on menu fetch error → Fixed
  • specialDayPricing always 0 → Fixed (arrays now populated)
  • setInterval leak in DynamicPricing → Fixed
  • hh_menu ignores isAddOn → Fixed
  • isCorrectExtension breaks on query params → Fixed

Clarifications Needed

  • ⚠️ Is the absence of a Clevertap page-view event intentional, or was it lost during migration?
  • ⚠️ menuPopupEngine is set but never used — should gallery mode be restored, or is modal-only the intended behavior post-migration?