@ticketboothapp/booking 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/package.json +2 -1
  2. package/src/app/photo-sessions/photo-packages.ts +75 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2072 -354
  8. package/src/components/BookingWidget.tsx +28 -248
  9. package/src/components/JobApplicationDialog.module.css +440 -0
  10. package/src/components/JobApplicationDialog.tsx +620 -0
  11. package/src/components/ManageBookingView.tsx +28 -36
  12. package/src/components/PhoneInputWithCountry.module.css +131 -0
  13. package/src/components/PhoneInputWithCountry.tsx +44 -0
  14. package/src/components/PickupLocationDialog.module.css +360 -0
  15. package/src/components/PickupLocationDialog.tsx +357 -0
  16. package/src/components/PickupLocationMap.tsx +110 -0
  17. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  18. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  19. package/src/components/accordion.css +27 -0
  20. package/src/components/accordion.tsx +29 -0
  21. package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
  22. package/src/components/analytics/AnalyticsScripts.tsx +106 -0
  23. package/src/components/analytics/CookieConsentBanner.css +86 -0
  24. package/src/components/analytics/CookieConsentBanner.tsx +102 -0
  25. package/src/components/booking/AddOnsSection.module.css +10 -0
  26. package/src/components/booking/AddOnsSection.tsx +184 -0
  27. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  28. package/src/components/booking/BookingDialog.module.css +643 -0
  29. package/src/components/booking/BookingDialog.tsx +356 -0
  30. package/src/components/booking/BookingFlow.tsx +4385 -0
  31. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  32. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  33. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  34. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  35. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  36. package/src/components/booking/BookingProductGrid.module.css +359 -0
  37. package/src/components/booking/BookingProductGrid.tsx +497 -0
  38. package/src/components/booking/Calendar.module.css +616 -0
  39. package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
  40. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  41. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  42. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  43. package/src/components/booking/CheckoutForm.module.css +244 -0
  44. package/src/components/booking/CheckoutForm.tsx +364 -0
  45. package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
  46. package/src/components/booking/DapFlowCollage.tsx +88 -0
  47. package/src/components/booking/DapTourDescription.tsx +35 -0
  48. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  49. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  50. package/src/components/booking/InfoTooltip.tsx +108 -0
  51. package/src/components/booking/ItineraryBox.module.css +258 -0
  52. package/src/components/booking/ItineraryBox.tsx +550 -0
  53. package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
  54. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  55. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  56. package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
  57. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  58. package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
  59. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  60. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  61. package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
  62. package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
  63. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  64. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  65. package/src/components/booking/PromoCodeInput.module.css +166 -0
  66. package/src/components/booking/PromoCodeInput.tsx +99 -0
  67. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  68. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  69. package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
  70. package/src/components/booking/TicketSelector.module.css +164 -0
  71. package/src/components/booking/TicketSelector.tsx +199 -0
  72. package/src/components/booking/TourDescription.module.css +304 -0
  73. package/src/components/booking/TourDescription.tsx +273 -0
  74. package/src/components/booking/booking-flow-ui.ts +15 -1
  75. package/src/components/booking/booking-flow.css +944 -0
  76. package/src/components/bottom-sheet.module.css +78 -0
  77. package/src/components/bottom-sheet.tsx +60 -0
  78. package/src/components/breadcrumb.module.css +40 -0
  79. package/src/components/breadcrumb.tsx +36 -0
  80. package/src/components/button.css +245 -0
  81. package/src/components/button.tsx +152 -0
  82. package/src/components/client-bottom-sheet.tsx +14 -0
  83. package/src/components/colorable-svg.tsx +29 -0
  84. package/src/components/conditional-footer.tsx +27 -0
  85. package/src/components/contact-us.module.css +147 -0
  86. package/src/components/contact-us.tsx +49 -0
  87. package/src/components/email-signup.css +151 -0
  88. package/src/components/email-signup.tsx +63 -0
  89. package/src/components/faq-wrapper.module.css +47 -0
  90. package/src/components/faq-wrapper.tsx +15 -0
  91. package/src/components/footer.css +187 -0
  92. package/src/components/footer.tsx +143 -0
  93. package/src/components/global-simple-modal.tsx +33 -0
  94. package/src/components/google-review-summary.module.css +77 -0
  95. package/src/components/google-review-summary.tsx +50 -0
  96. package/src/components/hero-image.css +13 -0
  97. package/src/components/hero-image.tsx +44 -0
  98. package/src/components/image.css +29 -0
  99. package/src/components/image.tsx +113 -0
  100. package/src/components/language-aware-link.tsx +72 -0
  101. package/src/components/language-switcher.module.css +124 -0
  102. package/src/components/language-switcher.tsx +75 -0
  103. package/src/components/map-section.css +59 -0
  104. package/src/components/map-section.tsx +63 -0
  105. package/src/components/navbar.module.css +152 -0
  106. package/src/components/navbar.tsx +125 -0
  107. package/src/components/parallax-provider.tsx +11 -0
  108. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  109. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  110. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
  111. package/src/components/product-tag.module.css +30 -0
  112. package/src/components/product-tag.tsx +34 -0
  113. package/src/components/product-theme-pages/best-option.module.css +70 -0
  114. package/src/components/product-theme-pages/best-option.tsx +35 -0
  115. package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
  116. package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
  117. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  118. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  119. package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
  120. package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
  121. package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
  122. package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
  123. package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
  124. package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
  125. package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
  126. package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
  127. package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
  128. package/src/components/product-tile/image-only-product-tile.tsx +44 -0
  129. package/src/components/product-tile/product-tile-card.module.css +84 -0
  130. package/src/components/product-tile/product-tile-card.tsx +61 -0
  131. package/src/components/review-highlights-section.css +85 -0
  132. package/src/components/review-highlights-section.tsx +127 -0
  133. package/src/components/season-closure-overlay.module.css +99 -0
  134. package/src/components/season-closure-overlay.tsx +98 -0
  135. package/src/components/simple-modal.tsx +69 -0
  136. package/src/components/simple-top-of-fold.module.css +76 -0
  137. package/src/components/simple-top-of-fold.tsx +34 -0
  138. package/src/components/spacer.css +41 -0
  139. package/src/components/spacer.tsx +23 -0
  140. package/src/components/star-rating.module.css +74 -0
  141. package/src/components/star-rating.tsx +48 -0
  142. package/src/components/terms/TermsContent.tsx +178 -0
  143. package/src/components/title-subtitle.module.css +10 -0
  144. package/src/components/title-subtitle.tsx +30 -0
  145. package/src/components/translatable-reviews.tsx +75 -0
  146. package/src/components/value-pill.module.css +59 -0
  147. package/src/components/value-pill.tsx +46 -0
  148. package/src/components/value-props.css +185 -0
  149. package/src/components/value-props.tsx +88 -0
  150. package/src/constants/booking-guide-quiz.ts +64 -0
  151. package/src/constants/contact-info.ts +2 -0
  152. package/src/constants/faq.ts +44 -0
  153. package/src/constants/images.ts +556 -0
  154. package/src/constants/json-ld/faq-json-ld.tsx +170 -0
  155. package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
  156. package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
  157. package/src/constants/json-ld/organization-json-ld.tsx +62 -0
  158. package/src/constants/json-ld/page-json-ld.tsx +6 -0
  159. package/src/constants/json-ld/product-json-ld.tsx +154 -0
  160. package/src/constants/json-ld/review-json-ld.tsx +377 -0
  161. package/src/constants/navigation-links/footer-links.ts +48 -0
  162. package/src/constants/navigation-links/nav-bar-links.ts +41 -0
  163. package/src/constants/navigation-links/navigation-link.ts +6 -0
  164. package/src/constants/pill-values.ts +210 -0
  165. package/src/constants/products.ts +155 -0
  166. package/src/constants/quiz-recommendations.ts +506 -0
  167. package/src/constants/reviews.ts +75 -0
  168. package/src/constants/staff.ts +197 -0
  169. package/src/constants/value-props.ts +58 -0
  170. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  171. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  172. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  173. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  174. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  175. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  176. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  177. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  178. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  179. package/src/data/product-descriptions/private-tour.en.json +80 -0
  180. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  181. package/src/data/products-config.json +101 -0
  182. package/src/hooks/use-bottom-sheet.tsx +15 -0
  183. package/src/hooks/use-simple-modal.tsx +27 -0
  184. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  185. package/src/hooks/useEmailSubscription.tsx +103 -0
  186. package/src/hooks/useEmbeddedInIframe.ts +16 -0
  187. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  188. package/src/hooks/useQuiz.tsx +210 -0
  189. package/src/index.ts +27 -2
  190. package/src/lib/analytics.ts +197 -0
  191. package/src/lib/booking/booking-source.ts +20 -2
  192. package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
  193. package/src/lib/booking/correlation-id.ts +46 -0
  194. package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
  195. package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
  196. package/src/lib/booking/itinerary-display.ts +36 -0
  197. package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
  198. package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
  199. package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
  200. package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
  201. package/src/lib/booking/normalize-booking-product-id.ts +7 -0
  202. package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
  203. package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
  204. package/src/lib/booking/product-option-id.ts +35 -0
  205. package/src/lib/booking/source-metadata.ts +72 -7
  206. package/src/lib/booking/sunday-week.ts +14 -0
  207. package/src/lib/booking/trace-context.ts +62 -0
  208. package/src/lib/booking-api.ts +1793 -0
  209. package/src/lib/{constants.ts → booking-constants.ts} +11 -5
  210. package/src/lib/booking-types.ts +36 -0
  211. package/src/lib/currency.ts +38 -45
  212. package/src/lib/dap-descriptions.ts +50 -0
  213. package/src/lib/dap-itinerary-preview.ts +315 -0
  214. package/src/lib/dependent-add-on-api.ts +434 -0
  215. package/src/lib/env.ts +89 -5
  216. package/src/lib/firebase.ts +20 -0
  217. package/src/lib/job-application-api.ts +83 -0
  218. package/src/lib/manage-booking-embed-print.ts +16 -0
  219. package/src/lib/manage-booking-post-checkout.ts +68 -0
  220. package/src/lib/photo-dap-config.ts +228 -0
  221. package/src/lib/pickup/map-utils.ts +56 -0
  222. package/src/lib/pickup/marker-icons.ts +19 -0
  223. package/src/lib/product-descriptions.ts +66 -0
  224. package/src/lib/products-config.ts +73 -0
  225. package/src/providers/booking-dialog-provider.tsx +107 -38
  226. package/src/providers/bottom-sheet-provider.tsx +40 -0
  227. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  228. package/src/radius.css +5 -0
  229. package/src/spacing.css +7 -0
  230. package/src/strings/en.json +1774 -0
  231. package/src/strings/es.json +1573 -0
  232. package/src/strings/fr.json +1573 -0
  233. package/src/strings/index.js +23 -0
  234. package/src/text-style.css +97 -0
  235. package/src/types/fareharbor.d.ts +12 -0
  236. package/src/types/quiz.ts +59 -0
  237. package/src/utils/currency-converter.ts +101 -0
  238. package/src/components/BookingFlow.tsx +0 -2952
  239. package/src/components/LanguageSwitcher.tsx +0 -30
  240. package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
  241. package/src/components/ProductList.tsx +0 -78
  242. package/src/components/WhatsAppPhoneInput.tsx +0 -224
  243. package/src/components/index.ts +0 -31
  244. package/src/lib/api.ts +0 -801
  245. package/src/lib/booking-api-auth.ts +0 -9
  246. package/src/lib/checkout-breakdown.test.ts +0 -70
  247. package/src/types/google-maps.d.ts +0 -2
  248. /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
  249. /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
  250. /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
  251. /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
  252. /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
  253. /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
  254. /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
  255. /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
@@ -0,0 +1,407 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { motion, useReducedMotion } from 'framer-motion';
5
+ import {
6
+ PHOTO_DAP_SLUGS,
7
+ getPhotoDapCatalog,
8
+ type PhotoDapCatalog,
9
+ type PhotoDapSlug,
10
+ } from '@/lib/photo-dap-config';
11
+ import {
12
+ PHOTO_PACKAGE_SECTIONS,
13
+ type PhotoPackage,
14
+ } from '@/app/photo-sessions/photo-packages';
15
+ import { getDependentAddOnBookingUpsellEligibility } from '@/lib/dependent-add-on-api';
16
+ import { ENV } from '@/lib/env';
17
+ import {
18
+ useDependentAddOnDialog,
19
+ type DependentAddOnDialogOpenPayload,
20
+ } from '@/providers/dependent-add-on-dialog-provider';
21
+ import type { BookingData } from '@/components/BookingDetails';
22
+ import ViaViaImage from '@/components/image';
23
+ import styles from './PostBookingDependentAddOnUpsell.module.css';
24
+
25
+ const easeOut = [0.22, 1, 0.36, 1] as const;
26
+
27
+ /** Session cache TTL — balances fewer round-trips vs slot inventory freshness. */
28
+ const DAP_UPSELL_PROBE_TTL_MS = 5 * 60 * 1000;
29
+ const DAP_UPSELL_PROBE_STORAGE_PREFIX = 'vvia:dapUpsellProbe:v2:';
30
+
31
+ type DapUpsellEntry = { slug: PhotoDapSlug; optionId?: string };
32
+
33
+ function isAbortError(e: unknown): boolean {
34
+ if (e instanceof Error && e.name === 'AbortError') return true;
35
+ return (
36
+ typeof DOMException !== 'undefined' &&
37
+ e instanceof DOMException &&
38
+ e.name === 'AbortError'
39
+ );
40
+ }
41
+
42
+ function isPhotoDapSlug(s: string): s is PhotoDapSlug {
43
+ return (PHOTO_DAP_SLUGS as readonly string[]).includes(s);
44
+ }
45
+
46
+ function dapUpsellProbeCacheKey(
47
+ companyId: string,
48
+ ref: string,
49
+ ln: string,
50
+ bookingVersionStamp: string
51
+ ): string {
52
+ return `${DAP_UPSELL_PROBE_STORAGE_PREFIX}${companyId}:${encodeURIComponent(ref)}:${encodeURIComponent(ln)}:${encodeURIComponent(bookingVersionStamp)}`;
53
+ }
54
+
55
+ /** `undefined` = cache miss; `[]` = cached “no packages with slots”. */
56
+ function readDapUpsellProbeCache(key: string): DapUpsellEntry[] | undefined {
57
+ if (typeof sessionStorage === 'undefined') return undefined;
58
+ try {
59
+ const raw = sessionStorage.getItem(key);
60
+ if (!raw) return undefined;
61
+ const parsed = JSON.parse(raw) as {
62
+ entries?: unknown;
63
+ slugs?: unknown;
64
+ savedAt?: unknown;
65
+ };
66
+ if (typeof parsed.savedAt !== 'number') {
67
+ sessionStorage.removeItem(key);
68
+ return undefined;
69
+ }
70
+ if (Date.now() - parsed.savedAt > DAP_UPSELL_PROBE_TTL_MS) {
71
+ sessionStorage.removeItem(key);
72
+ return undefined;
73
+ }
74
+ if (Array.isArray(parsed.entries)) {
75
+ const out: DapUpsellEntry[] = [];
76
+ for (const row of parsed.entries) {
77
+ if (!row || typeof row !== 'object') continue;
78
+ const o = row as Record<string, unknown>;
79
+ const slug = o.slug;
80
+ const optionId = o.optionId;
81
+ if (typeof slug === 'string' && isPhotoDapSlug(slug)) {
82
+ out.push({
83
+ slug,
84
+ ...(typeof optionId === 'string' && optionId.trim()
85
+ ? { optionId: optionId.trim() }
86
+ : {}),
87
+ });
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ if (Array.isArray(parsed.slugs)) {
93
+ const out: DapUpsellEntry[] = [];
94
+ for (const s of parsed.slugs) {
95
+ if (typeof s === 'string' && isPhotoDapSlug(s)) out.push({ slug: s });
96
+ }
97
+ return out;
98
+ }
99
+ sessionStorage.removeItem(key);
100
+ return undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function writeDapUpsellProbeCache(key: string, entries: DapUpsellEntry[]): void {
107
+ if (typeof sessionStorage === 'undefined') return;
108
+ try {
109
+ sessionStorage.setItem(key, JSON.stringify({ entries, savedAt: Date.now() }));
110
+ } catch {
111
+ /* quota / private mode */
112
+ }
113
+ }
114
+
115
+ function findPhotoPackageByDapSlug(slug: PhotoDapSlug): PhotoPackage | undefined {
116
+ for (const section of PHOTO_PACKAGE_SECTIONS) {
117
+ const p = section.photoPackages.find((pkg) => pkg.dapSlug === slug);
118
+ if (p) return p;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ function buildOpenPayload(
124
+ slug: PhotoDapSlug,
125
+ photoPackage: PhotoPackage,
126
+ catalog: PhotoDapCatalog,
127
+ initialPrimaryBookingReference: string,
128
+ initialPrimaryBookingLastName?: string,
129
+ /** Option TicketBooth used when probing slots — pre-select only; must not set fixed option if user can pick lengths. */
130
+ upsellProbedOptionId?: string
131
+ ): DependentAddOnDialogOpenPayload {
132
+ const fixedFromCatalog = catalog.dependentAddOnProductOptionId?.trim();
133
+ const probed = upsellProbedOptionId?.trim();
134
+ const multiSessionLengths = (catalog.productOptions?.length ?? 0) > 1;
135
+ const probedMatchesOption =
136
+ Boolean(probed) &&
137
+ catalog.productOptions?.some((o) => o.dependentAddOnProductOptionId === probed);
138
+ /** Upsell must not set `dependentAddOnProductOptionId` when multiple lengths exist — that hides the session picker. */
139
+ const initialSelectedFromUpsell =
140
+ !fixedFromCatalog && multiSessionLengths && probedMatchesOption ? probed : undefined;
141
+
142
+ return {
143
+ productDisplayTitle: photoPackage.name,
144
+ dependentAddOnProductId: catalog.dependentAddOnProductId,
145
+ cancellationDaysBeforeSession: catalog.cancellationDaysBeforeSession,
146
+ collageImageIds:
147
+ catalog.collageImageIds?.length > 0
148
+ ? catalog.collageImageIds
149
+ : photoPackage.images.map((img) => img.id),
150
+ dapDescriptionSlug: slug,
151
+ initialPrimaryBookingReference,
152
+ initialPrimaryBookingLastName,
153
+ ...(fixedFromCatalog ? { dependentAddOnProductOptionId: fixedFromCatalog } : {}),
154
+ ...(initialSelectedFromUpsell
155
+ ? { initialSelectedProductOptionId: initialSelectedFromUpsell }
156
+ : {}),
157
+ ...(catalog.productOptions?.length
158
+ ? {
159
+ productOptions: catalog.productOptions.map((o) => ({
160
+ dependentAddOnProductOptionId: o.dependentAddOnProductOptionId,
161
+ label: o.label,
162
+ photosLabel: o.photosLabel,
163
+ startingAtLabel: o.startingAtLabel,
164
+ })),
165
+ }
166
+ : {}),
167
+ };
168
+ }
169
+
170
+ /** Map aggregate API rows to photo slugs in `PHOTO_DAP_SLUGS` order (env-resolved product ids). */
171
+ function upsellEntriesFromProductsWithSlots(
172
+ productsWithSlots: { dependentAddOnProductId: string; dependentAddOnProductOptionId?: string }[]
173
+ ): DapUpsellEntry[] {
174
+ const byProductId = new Map(
175
+ productsWithSlots.map((p) => [p.dependentAddOnProductId, p] as const)
176
+ );
177
+ const ordered: DapUpsellEntry[] = [];
178
+ for (const slug of PHOTO_DAP_SLUGS) {
179
+ const catalog = getPhotoDapCatalog(slug);
180
+ if (!catalog) continue;
181
+ const row = byProductId.get(catalog.dependentAddOnProductId);
182
+ if (!row) continue;
183
+ const opt = row.dependentAddOnProductOptionId?.trim();
184
+ ordered.push({
185
+ slug,
186
+ ...(opt ? { optionId: opt } : {}),
187
+ });
188
+ }
189
+ return ordered;
190
+ }
191
+
192
+ export function PostBookingDependentAddOnUpsell({
193
+ booking,
194
+ enabled,
195
+ }: {
196
+ booking: BookingData;
197
+ enabled: boolean;
198
+ }) {
199
+ const { open: openDapDialog } = useDependentAddOnDialog();
200
+ const reduceMotion = useReducedMotion();
201
+ const [checking, setChecking] = useState(true);
202
+ const [availableEntries, setAvailableEntries] = useState<DapUpsellEntry[]>([]);
203
+
204
+ const primaryRefForApi = useMemo(
205
+ () => booking.bookingReference.trim(),
206
+ [booking.bookingReference]
207
+ );
208
+ const primaryLastNameForApi = useMemo(
209
+ () => booking.customer?.lastName?.trim() || '',
210
+ [booking.customer?.lastName]
211
+ );
212
+
213
+ /** Re-run probe when booking payload changes in ways that can affect slot eligibility. */
214
+ const bookingProbeStamp = useMemo(
215
+ () =>
216
+ [
217
+ booking.bookingReference.trim(),
218
+ booking.customer?.lastName?.trim() ?? '',
219
+ String(booking.dependentAddOnBookings?.length ?? 0),
220
+ booking.updatedAt ?? '',
221
+ booking.dateTime ?? '',
222
+ ].join('|'),
223
+ [
224
+ booking.bookingReference,
225
+ booking.customer?.lastName,
226
+ booking.dependentAddOnBookings?.length,
227
+ booking.updatedAt,
228
+ booking.dateTime,
229
+ ]
230
+ );
231
+
232
+ const skip =
233
+ !enabled ||
234
+ /** Partner portal / embed: never probe slots (no upsell UI); avoids unnecessary API calls. */
235
+ (typeof window !== 'undefined' && window.parent !== window.self) ||
236
+ (booking.gygBookingReference ?? '').trim().length > 0 ||
237
+ ['CANCELLED', 'CANCELED'].includes((booking.status ?? '').toUpperCase()) ||
238
+ (booking.dependentAddOnBookings?.length ?? 0) > 0;
239
+
240
+ useEffect(() => {
241
+ if (skip) {
242
+ setChecking(false);
243
+ setAvailableEntries([]);
244
+ return;
245
+ }
246
+
247
+ const ac = new AbortController();
248
+ const { signal } = ac;
249
+ let cancelled = false;
250
+
251
+ const cacheKey = dapUpsellProbeCacheKey(
252
+ ENV.COMPANY_ID,
253
+ primaryRefForApi,
254
+ primaryLastNameForApi,
255
+ bookingProbeStamp
256
+ );
257
+
258
+ const run = async () => {
259
+ const cached = readDapUpsellProbeCache(cacheKey);
260
+ if (cached !== undefined) {
261
+ if (!cancelled && !signal.aborted) {
262
+ setAvailableEntries(cached);
263
+ setChecking(false);
264
+ }
265
+ return;
266
+ }
267
+
268
+ if (!cancelled && !signal.aborted) {
269
+ setChecking(true);
270
+ setAvailableEntries([]);
271
+ }
272
+
273
+ try {
274
+ if (!primaryLastNameForApi) {
275
+ if (!cancelled && !signal.aborted) {
276
+ writeDapUpsellProbeCache(cacheKey, []);
277
+ setAvailableEntries([]);
278
+ }
279
+ } else {
280
+ const { productsWithSlots } = await getDependentAddOnBookingUpsellEligibility({
281
+ companyId: ENV.COMPANY_ID,
282
+ primaryBookingReference: primaryRefForApi,
283
+ lastName: primaryLastNameForApi,
284
+ signal,
285
+ });
286
+ if (cancelled || signal.aborted) return;
287
+ const ordered = upsellEntriesFromProductsWithSlots(productsWithSlots);
288
+ writeDapUpsellProbeCache(cacheKey, ordered);
289
+ setAvailableEntries(ordered);
290
+ }
291
+ } catch (e) {
292
+ if (isAbortError(e) || cancelled || signal.aborted) return;
293
+ setAvailableEntries([]);
294
+ } finally {
295
+ if (!cancelled && !signal.aborted) {
296
+ setChecking(false);
297
+ }
298
+ }
299
+ };
300
+
301
+ void run();
302
+
303
+ return () => {
304
+ cancelled = true;
305
+ ac.abort();
306
+ };
307
+ }, [skip, bookingProbeStamp, primaryRefForApi, primaryLastNameForApi]);
308
+
309
+ const handleOpen = useCallback(
310
+ (slug: PhotoDapSlug, upsellProbedOptionId?: string) => {
311
+ const catalog = getPhotoDapCatalog(slug);
312
+ const photoPackage = findPhotoPackageByDapSlug(slug);
313
+ if (!catalog || !photoPackage) return;
314
+ openDapDialog(
315
+ buildOpenPayload(
316
+ slug,
317
+ photoPackage,
318
+ catalog,
319
+ primaryRefForApi,
320
+ primaryLastNameForApi || undefined,
321
+ upsellProbedOptionId
322
+ )
323
+ );
324
+ },
325
+ [primaryRefForApi, primaryLastNameForApi, openDapDialog]
326
+ );
327
+
328
+ if (skip || checking || availableEntries.length === 0) return null;
329
+
330
+ const instant = reduceMotion
331
+ ? { duration: 0.01 }
332
+ : { duration: 0.48, ease: easeOut };
333
+ const headerMotion = reduceMotion
334
+ ? { duration: 0.01 }
335
+ : { duration: 0.4, delay: 0.06, ease: easeOut };
336
+ const cardMotion = (index: number) =>
337
+ reduceMotion
338
+ ? { duration: 0.01 }
339
+ : { duration: 0.44, delay: 0.12 + index * 0.09, ease: easeOut };
340
+
341
+ const shellEnter = reduceMotion ? { opacity: 0, y: 0 } : { opacity: 0, y: 20 };
342
+
343
+ const content = (
344
+ <>
345
+ <motion.div
346
+ className={styles.header}
347
+ initial={reduceMotion ? false : { opacity: 0, y: 12 }}
348
+ animate={{ opacity: 1, y: 0 }}
349
+ transition={headerMotion}
350
+ >
351
+ <h2 id="post-booking-dap-upsell-title" className={styles.title}>
352
+ Interested in adding a professional photography session to your tour?
353
+ </h2>
354
+ <p className={styles.subtitle}>
355
+ Photo sessions are available as an add-on for your shuttle date. Tap a package to pick a time and
356
+ add to your booking.
357
+ </p>
358
+ </motion.div>
359
+
360
+ <div className={styles.cardGrid}>
361
+ {availableEntries.map(({ slug, optionId }, index) => {
362
+ const pkg = findPhotoPackageByDapSlug(slug);
363
+ if (!pkg) return null;
364
+ const hero = pkg.images[0];
365
+ return (
366
+ <motion.button
367
+ key={slug}
368
+ type="button"
369
+ className={styles.card}
370
+ onClick={() => handleOpen(slug, optionId)}
371
+ initial={reduceMotion ? false : { opacity: 0, y: 22, scale: 0.96 }}
372
+ animate={{ opacity: 1, y: 0, scale: 1 }}
373
+ transition={cardMotion(index)}
374
+ >
375
+ <div className={styles.imageWrap}>
376
+ <ViaViaImage
377
+ className={styles.cardImage}
378
+ imageId={hero.id}
379
+ alt={hero.alt}
380
+ context="GALLERY"
381
+ />
382
+ <h3 className={styles.cardTitle}>{pkg.name}</h3>
383
+ <div className={styles.cardInfo}>
384
+ <p className={styles.infoLine}>{pkg.startingPrice}</p>
385
+ <p className={styles.infoLine}>{pkg.duration}</p>
386
+ <p className={styles.infoLine}>{pkg.quantity}</p>
387
+ </div>
388
+ </div>
389
+ </motion.button>
390
+ );
391
+ })}
392
+ </div>
393
+ </>
394
+ );
395
+
396
+ return (
397
+ <motion.div
398
+ className={styles.section}
399
+ aria-labelledby="post-booking-dap-upsell-title"
400
+ initial={reduceMotion ? false : shellEnter}
401
+ animate={{ opacity: 1, y: 0 }}
402
+ transition={instant}
403
+ >
404
+ {content}
405
+ </motion.div>
406
+ );
407
+ }
@@ -0,0 +1,27 @@
1
+ .via-via-accordion-base {
2
+ background-color: var(--light-orange-background-dark);
3
+ box-shadow: none;
4
+ width: 100%;
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ .via-via-accordion-base button[data-slot="trigger"] {
9
+ cursor: pointer;
10
+ }
11
+
12
+ .via-via-accordion-content {
13
+ width: 100%;
14
+ box-sizing: border-box;
15
+ cursor: pointer;
16
+ }
17
+
18
+ .via-via-accordion-title {
19
+ color: var(--primary-text);
20
+ font-family: 'Figtree', sans-serif;
21
+ font-weight: 400;
22
+ font-size: 1.25rem;
23
+ }
24
+
25
+ .accordion-icon {
26
+ color: var(--accent-orange);
27
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import {Accordion, AccordionItem} from "@heroui/accordion";
4
+ import "./accordion.css";
5
+ import PlusIcon from "@/assets/icons/plus.svg";
6
+ import MinusIcon from "@/assets/icons/minus.svg";
7
+ import { faqItem } from "@/constants/faq";
8
+
9
+ export default function AccordionComponent({items, className, selectionMode}: {items: faqItem[], className: string, selectionMode: "single" | "multiple"}) {
10
+ return (
11
+ <Accordion
12
+ selectionMode={selectionMode}
13
+ variant="splitted"
14
+ className={className}
15
+ itemClasses={{
16
+ base: "via-via-accordion-base",
17
+ title: "via-via-accordion-title",
18
+ trigger: "bg-transparent hover:bg-transparent border-none outline-none",
19
+ content: "via-via-accordion-content",
20
+ }}
21
+ >
22
+ {items.map((item) => (
23
+ <AccordionItem key={item.question} aria-label={item.question} title={item.question} indicator={({isOpen}) => (isOpen ? <MinusIcon className="accordion-icon" /> : <PlusIcon className="accordion-icon" />)}>
24
+ <div dangerouslySetInnerHTML={{__html: item.answer}} />
25
+ </AccordionItem>
26
+ ))}
27
+ </Accordion>
28
+ );
29
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { updateAnalyticsConsent } from './AnalyticsScripts';
5
+ import { hasAnalyticsConsent } from '@/lib/analytics';
6
+
7
+ /**
8
+ * Restores analytics consent on load if user previously accepted.
9
+ * Must run after AnalyticsScripts have loaded gtag/fbq.
10
+ */
11
+ export function AnalyticsConsentRestore() {
12
+ useEffect(() => {
13
+ if (hasAnalyticsConsent()) {
14
+ updateAnalyticsConsent(true);
15
+ }
16
+ }, []);
17
+
18
+ return null;
19
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import Script from 'next/script';
5
+ import { ENV, isLocalhost, isProduction } from '@/lib/env';
6
+ import { setAnalyticsConsentGranted } from '@/lib/analytics';
7
+
8
+ const gaId = ENV.GA4_MEASUREMENT_ID;
9
+ const pixelId = ENV.META_PIXEL_ID;
10
+
11
+ /** Update GA4 and Meta consent. Call when user accepts cookies or on load if previously granted. */
12
+ export function updateAnalyticsConsent(granted: boolean): void {
13
+ if (granted) {
14
+ setAnalyticsConsentGranted();
15
+ if (typeof window !== 'undefined') {
16
+ if (window.gtag) {
17
+ window.gtag('consent', 'update', {
18
+ ad_user_data: 'granted',
19
+ ad_personalization: 'granted',
20
+ ad_storage: 'granted',
21
+ analytics_storage: 'granted',
22
+ });
23
+ }
24
+ if (window.fbq) {
25
+ window.fbq('consent', 'grant');
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ export function AnalyticsScripts() {
32
+ // Don't load on localhost (prod build testing) or when not production env
33
+ const [shouldLoad, setShouldLoad] = useState(false);
34
+ useEffect(() => {
35
+ setShouldLoad(!isLocalhost() && isProduction() && (!!gaId || !!pixelId));
36
+ }, []);
37
+
38
+ if (!shouldLoad) return null;
39
+
40
+ return (
41
+ <>
42
+ {/* GA4 with Consent Mode v2 (default denied) */}
43
+ {gaId && (
44
+ <>
45
+ <Script
46
+ id="gtag-consent"
47
+ strategy="beforeInteractive"
48
+ dangerouslySetInnerHTML={{
49
+ __html: `
50
+ window.dataLayer = window.dataLayer || [];
51
+ function gtag(){dataLayer.push(arguments);}
52
+ gtag('js', new Date());
53
+ gtag('consent', 'default', {
54
+ ad_user_data: 'denied',
55
+ ad_personalization: 'denied',
56
+ ad_storage: 'denied',
57
+ analytics_storage: 'denied',
58
+ wait_for_update: 500
59
+ });
60
+ `,
61
+ }}
62
+ />
63
+ <Script
64
+ src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
65
+ strategy="afterInteractive"
66
+ />
67
+ <Script
68
+ id="gtag-config"
69
+ strategy="afterInteractive"
70
+ dangerouslySetInnerHTML={{
71
+ __html: `
72
+ window.dataLayer = window.dataLayer || [];
73
+ function gtag(){dataLayer.push(arguments);}
74
+ gtag('config', '${gaId}', { anonymize_ip: true });
75
+ `,
76
+ }}
77
+ />
78
+ </>
79
+ )}
80
+
81
+ {/* Meta Pixel with consent revoked by default */}
82
+ {pixelId && (
83
+ <Script
84
+ id="meta-pixel"
85
+ strategy="afterInteractive"
86
+ dangerouslySetInnerHTML={{
87
+ __html: `
88
+ !function(f,b,e,v,n,t,s)
89
+ {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
90
+ n.callMethod.apply(n,arguments):n.queue.push(arguments)};
91
+ if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
92
+ n.queue=[];t=b.createElement(e);t.async=!0;
93
+ t.src=v;s=b.getElementsByTagName(e)[0];
94
+ s.parentNode.insertBefore(t,s)}(window, document,'script',
95
+ 'https://connect.facebook.net/en_US/fbevents.js');
96
+ fbq('consent', 'revoke');
97
+ fbq('init', '${pixelId}');
98
+ fbq('track', 'PageView');
99
+ `,
100
+ }}
101
+ />
102
+ )}
103
+
104
+ </>
105
+ );
106
+ }
@@ -0,0 +1,86 @@
1
+ /* Cookie consent banner – customize appearance here */
2
+
3
+ .cookieConsentBanner {
4
+ position: fixed;
5
+ bottom: 0;
6
+ left: 0;
7
+ right: 0;
8
+ z-index: 50;
9
+ background-color: rgba(28, 25, 23, 0.9);
10
+ color: #ffffff;
11
+ padding: 1rem;
12
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
13
+ }
14
+
15
+ .cookieConsentBannerInner {
16
+ max-width: 850px;
17
+ margin-left: auto;
18
+ margin-right: auto;
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--spacing-small);
22
+ }
23
+
24
+ @media (min-width: 1023px) {
25
+ .cookieConsentBannerInner {
26
+ flex-direction: row;
27
+ align-items: center;
28
+ }
29
+ }
30
+
31
+ .cookieConsentBannerText {
32
+ flex: 1 1 0%;
33
+ font-size: 0.875rem;
34
+ line-height: 1.25rem;
35
+ color: #ffffff;
36
+ }
37
+
38
+ .cookieConsentBannerLink {
39
+ text-decoration: underline;
40
+ }
41
+
42
+ .cookieConsentBannerLink:hover {
43
+ color: #34d399;
44
+ }
45
+
46
+ .cookieConsentBannerButtons {
47
+ display: flex;
48
+ gap: 0.75rem;
49
+ width: 100%;
50
+ justify-content: center;
51
+ }
52
+
53
+ @media (min-width: 640px) {
54
+ .cookieConsentBannerButtons {
55
+ width: auto;
56
+ }
57
+ }
58
+
59
+ .cookieConsentBannerDecline {
60
+ padding: 0.5rem 1rem;
61
+ font-size: 0.875rem;
62
+ border: 1px solid #57534e;
63
+ border-radius: 0.5rem;
64
+ background: transparent;
65
+ color: inherit;
66
+ cursor: pointer;
67
+ }
68
+
69
+ .cookieConsentBannerDecline:hover {
70
+ background-color: #292524;
71
+ }
72
+
73
+ .cookieConsentBannerAccept {
74
+ padding: 0.5rem 1rem;
75
+ font-size: 0.875rem;
76
+ font-weight: 500;
77
+ background-color: #059669;
78
+ color: white;
79
+ border: none;
80
+ border-radius: 0.5rem;
81
+ cursor: pointer;
82
+ }
83
+
84
+ .cookieConsentBannerAccept:hover {
85
+ background-color: #047857;
86
+ }