@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
@@ -1,14 +1,16 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useEffect, useRef, type ReactNode } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { loadStripe } from '@stripe/stripe-js';
5
6
  import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
6
7
  import { ENV } from '@/lib/env';
8
+ import { storePendingPurchase, trackBeginCheckout } from '@/lib/analytics';
7
9
  import { formatCurrencyAmount } from '@/lib/currency';
8
10
  import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
9
11
  import type { Currency } from './CurrencySwitcher';
10
- import type { Locale } from '@/lib/i18n/config';
11
- import type { OrderSummaryTicketLine, OrderSummaryFeeLine, PriceBreakdown as PriceBreakdownType } from '@/lib/pricing';
12
+ import type { Locale } from '@/lib/booking/i18n/config';
13
+ import type { OrderSummaryTicketLine, OrderSummaryFeeLine, PriceBreakdown as PriceBreakdownType } from '@/lib/booking/pricing';
12
14
 
13
15
  export interface CheckoutModalLineItem {
14
16
  line: OrderSummaryTicketLine;
@@ -18,8 +20,11 @@ export interface CheckoutModalLineItem {
18
20
  export interface CheckoutModalProps {
19
21
  open: boolean;
20
22
  onClose: () => void;
23
+ onPaymentSubmitStart?: () => void;
24
+ onPaymentSubmitError?: () => void;
21
25
  clientSecret: string;
22
26
  reservationReference: string;
27
+ reservationExpiration?: string;
23
28
  /** Last name for Stripe return_url so success page can poll and redirect to /manage */
24
29
  customerLastName?: string;
25
30
  /** When set (e.g. provider-dashboard), used as Stripe return_url instead of /manage */
@@ -44,6 +49,16 @@ export interface CheckoutModalProps {
44
49
  isDepositPayment?: boolean;
45
50
  /** Days before booking to charge balance (e.g. 7). */
46
51
  balanceChargeDaysBefore?: number;
52
+ /**
53
+ * When `clientSecret` is not yet available, show this instead of the loading placeholder
54
+ * (e.g. collect email / last name before creating a PaymentIntent).
55
+ */
56
+ prePaymentPanel?: ReactNode;
57
+ changeTotals?: {
58
+ previousTotal: number;
59
+ newTotal: number;
60
+ differenceTotal: number;
61
+ };
47
62
  }
48
63
 
49
64
  const stripePromise = ENV.STRIPE_PUBLISHABLE_KEY
@@ -53,6 +68,8 @@ const stripePromise = ENV.STRIPE_PUBLISHABLE_KEY
53
68
  function CheckoutForm({
54
69
  successUrl,
55
70
  onClose,
71
+ onPaymentSubmitStart,
72
+ onPaymentSubmitError,
56
73
  t,
57
74
  total,
58
75
  currency,
@@ -60,6 +77,8 @@ function CheckoutForm({
60
77
  }: {
61
78
  successUrl: string;
62
79
  onClose: () => void;
80
+ onPaymentSubmitStart?: () => void;
81
+ onPaymentSubmitError?: () => void;
63
82
  t: (key: string) => string;
64
83
  total: number;
65
84
  currency: Currency;
@@ -79,8 +98,12 @@ function CheckoutForm({
79
98
  if (submitError) {
80
99
  setError(submitError.message ?? 'Validation failed');
81
100
  setLoading(false);
101
+ onPaymentSubmitError?.();
82
102
  return;
83
103
  }
104
+ onPaymentSubmitStart?.();
105
+ // Store before redirect so success page can fire purchase event
106
+ storePendingPurchase(total, currency);
84
107
  const { error: confirmError } = await stripe.confirmPayment({
85
108
  elements,
86
109
  confirmParams: {
@@ -89,6 +112,7 @@ function CheckoutForm({
89
112
  });
90
113
  if (confirmError) {
91
114
  setError(confirmError.message ?? 'Payment failed');
115
+ onPaymentSubmitError?.();
92
116
  }
93
117
  setLoading(false);
94
118
  };
@@ -124,8 +148,11 @@ function CheckoutForm({
124
148
  export function CheckoutModal({
125
149
  open,
126
150
  onClose,
151
+ onPaymentSubmitStart,
152
+ onPaymentSubmitError,
127
153
  clientSecret,
128
154
  reservationReference,
155
+ reservationExpiration,
129
156
  customerLastName,
130
157
  successUrlOverride,
131
158
  ticketLines,
@@ -146,17 +173,72 @@ export function CheckoutModal({
146
173
  t,
147
174
  isDepositPayment = false,
148
175
  balanceChargeDaysBefore = 7,
176
+ prePaymentPanel,
177
+ changeTotals,
149
178
  }: CheckoutModalProps) {
150
179
  const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
151
180
  const manageParams = new URLSearchParams({ reservationRef: reservationReference });
152
181
  if (customerLastName?.trim()) manageParams.set('lastName', customerLastName.trim());
153
- const successUrl = successUrlOverride ?? `${baseUrl}/manage?${manageParams.toString()}`;
182
+ if (!successUrlOverride) manageParams.set('booking_complete', '1');
183
+ const successUrl = successUrlOverride ?? `${baseUrl}/manage-booking?${manageParams.toString()}`;
184
+
185
+ const hasFiredBeginCheckout = useRef(false);
186
+ /** Ensures we only fire expiry close once per modal open (avoids Strict Mode / repeated 0 remainingMs). */
187
+ const holdExpiryCloseSentRef = useRef(false);
188
+ const [nowMs, setNowMs] = useState(() => Date.now());
189
+ useEffect(() => {
190
+ if (open && !hasFiredBeginCheckout.current) {
191
+ hasFiredBeginCheckout.current = true;
192
+ const items = ticketLines.map(({ line }) => ({
193
+ id: line.category,
194
+ name: line.category,
195
+ qty: line.qty,
196
+ price: line.itemTotal,
197
+ }));
198
+ trackBeginCheckout(total, currency, items);
199
+ }
200
+ if (!open) hasFiredBeginCheckout.current = false;
201
+ }, [open, total, currency, ticketLines]);
202
+
203
+ useEffect(() => {
204
+ if (!open || !reservationExpiration) return;
205
+ const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
206
+ return () => window.clearInterval(timer);
207
+ }, [open, reservationExpiration]);
208
+
209
+ const expirationMs = reservationExpiration ? Date.parse(reservationExpiration) : NaN;
210
+ const hasValidExpiration = Number.isFinite(expirationMs);
211
+ const remainingMs = hasValidExpiration ? Math.max(0, expirationMs - nowMs) : 0;
212
+ const remainingTotalSeconds = Math.ceil(remainingMs / 1000);
213
+ const remainingMinutes = Math.floor(remainingTotalSeconds / 60);
214
+ const remainingSeconds = remainingTotalSeconds % 60;
215
+ const remainingDisplay = `${String(remainingMinutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
216
+ const holdTone =
217
+ remainingTotalSeconds <= 120
218
+ ? 'critical'
219
+ : remainingTotalSeconds <= 300
220
+ ? 'warning'
221
+ : 'normal';
222
+
223
+ useEffect(() => {
224
+ if (!open) {
225
+ holdExpiryCloseSentRef.current = false;
226
+ return;
227
+ }
228
+ if (!hasValidExpiration || remainingMs > 0) return;
229
+ if (holdExpiryCloseSentRef.current) return;
230
+ holdExpiryCloseSentRef.current = true;
231
+ onClose();
232
+ }, [open, hasValidExpiration, remainingMs, onClose]);
154
233
 
155
234
  if (!open) return null;
156
235
 
157
236
  if (!ENV.STRIPE_PUBLISHABLE_KEY) {
158
- return (
159
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
237
+ const noStripe = (
238
+ <div
239
+ className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
240
+ style={{ zIndex: 100_000 }}
241
+ >
160
242
  <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
161
243
  <h3 className="text-lg font-semibold text-stone-900 mb-2">
162
244
  {t('booking.checkout') || 'Checkout'}
@@ -174,6 +256,7 @@ export function CheckoutModal({
174
256
  </div>
175
257
  </div>
176
258
  );
259
+ return typeof document !== 'undefined' ? createPortal(noStripe, document.body) : null;
177
260
  }
178
261
 
179
262
  const options = {
@@ -187,8 +270,11 @@ export function CheckoutModal({
187
270
  },
188
271
  };
189
272
 
190
- return (
191
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
273
+ const checkout = (
274
+ <div
275
+ className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
276
+ style={{ zIndex: 100_000 }}
277
+ >
192
278
  <div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col">
193
279
  <div className="p-6 border-b border-stone-200 flex-shrink-0">
194
280
  <div className="flex justify-between items-start">
@@ -238,12 +324,18 @@ export function CheckoutModal({
238
324
  },
239
325
  ]
240
326
  : []),
241
- ...feeLineItems.map((fee) => ({
242
- kind: 'line' as const,
243
- label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
244
- amount: fee.totalAmount,
245
- type: 'fee',
246
- })),
327
+ ...feeLineItems.map((fee) => {
328
+ const isMoraineLakeRoadAccessFee = fee.name.toLowerCase().includes('moraine') && (fee.name.toLowerCase().includes('access') || fee.name.toLowerCase().includes('road') || fee.name.toLowerCase().includes('license'));
329
+ return {
330
+ kind: 'line' as const,
331
+ label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
332
+ amount: fee.totalAmount,
333
+ type: 'fee',
334
+ tooltip: isMoraineLakeRoadAccessFee
335
+ ? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
336
+ : undefined,
337
+ };
338
+ }),
247
339
  ]}
248
340
  total={total}
249
341
  currency={currency}
@@ -255,18 +347,37 @@ export function CheckoutModal({
255
347
  taxRate={taxRate}
256
348
  size="sm"
257
349
  t={t}
258
- depositMode={isDepositPayment && subtotal > total ? { totalLabel: t('booking.deposit') || 'Deposit', balanceAmount: subtotal - total } : undefined}
350
+ depositMode={isDepositPayment && subtotal > total ? { totalLabel: t('booking.deposit') || 'Deposit', balanceAmount: subtotal - total, fullTotalAmount: subtotal } : undefined}
259
351
  hideSubtotal={isDepositPayment}
352
+ extraBetweenTaxAndTotal={
353
+ changeTotals ? (
354
+ <div className="space-y-1 pt-2 border-t border-stone-200">
355
+ <div className="flex justify-between gap-3 min-w-0 text-sm">
356
+ <span className="text-stone-600 min-w-0 truncate">New Total</span>
357
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
358
+ {formatCurrencyAmount(changeTotals.newTotal, currency, locale)}
359
+ </span>
360
+ </div>
361
+ <div className="flex justify-between gap-3 min-w-0 text-sm">
362
+ <span className="text-stone-600 min-w-0 truncate">Previous Total</span>
363
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
364
+ {formatCurrencyAmount(changeTotals.previousTotal, currency, locale)}
365
+ </span>
366
+ </div>
367
+ </div>
368
+ ) : undefined
369
+ }
370
+ totalLabel={changeTotals ? 'New Booking Difference' : undefined}
260
371
  />
261
372
  </div>
262
373
 
263
374
  {/* Deposit payment notice */}
264
375
  {isDepositPayment && (
265
- <div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
266
- <p className="font-medium">
376
+ <div className="deposit-notice mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
377
+ <p className="text-xs font-medium">
267
378
  {(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice' ? t('booking.depositPaymentNotice') : null) ?? "You're paying the deposit today."}
268
379
  </p>
269
- <p className="mt-1 text-amber-700">
380
+ <p className="mt-1 text-xs text-amber-700/90">
270
381
  {balanceChargeDaysBefore > 0
271
382
  ? (t('booking.balanceChargeNotice', { days: balanceChargeDaysBefore }) !== 'booking.balanceChargeNotice'
272
383
  ? t('booking.balanceChargeNotice', { days: balanceChargeDaysBefore })
@@ -277,13 +388,58 @@ export function CheckoutModal({
277
388
  </p>
278
389
  </div>
279
390
  )}
391
+ {hasValidExpiration && (
392
+ <div
393
+ className={`mt-4 p-3 rounded-lg border ${
394
+ holdTone === 'critical'
395
+ ? 'bg-red-50 border-red-300'
396
+ : holdTone === 'warning'
397
+ ? 'bg-amber-50 border-amber-300'
398
+ : 'bg-emerald-50 border-emerald-200'
399
+ }`}
400
+ >
401
+ <p
402
+ className={`text-xs font-semibold ${
403
+ holdTone === 'critical'
404
+ ? 'text-red-900'
405
+ : holdTone === 'warning'
406
+ ? 'text-amber-900'
407
+ : 'text-emerald-900'
408
+ }`}
409
+ >
410
+ Your reservation is being held for {remainingDisplay}
411
+ </p>
412
+ <p
413
+ className={`mt-1 text-xs ${
414
+ holdTone === 'critical'
415
+ ? 'text-red-800'
416
+ : holdTone === 'warning'
417
+ ? 'text-amber-800'
418
+ : 'text-emerald-800'
419
+ }`}
420
+ >
421
+ Closing this payment dialog releases the hold and you will need to reserve again.
422
+ </p>
423
+ </div>
424
+ )}
280
425
  </div>
281
426
 
282
427
  <div className="p-6 overflow-y-auto flex-1">
283
428
  {stripePromise && clientSecret ? (
284
429
  <Elements stripe={stripePromise} options={options}>
285
- <CheckoutForm successUrl={successUrl} onClose={onClose} t={t} total={total} currency={currency} locale={locale} />
430
+ <CheckoutForm
431
+ successUrl={successUrl}
432
+ onClose={onClose}
433
+ onPaymentSubmitStart={onPaymentSubmitStart}
434
+ onPaymentSubmitError={onPaymentSubmitError}
435
+ t={t}
436
+ total={total}
437
+ currency={currency}
438
+ locale={locale}
439
+ />
286
440
  </Elements>
441
+ ) : prePaymentPanel ? (
442
+ prePaymentPanel
287
443
  ) : (
288
444
  <p className="text-stone-500">{t('booking.loadingPayment') || 'Loading payment form...'}</p>
289
445
  )}
@@ -291,4 +447,5 @@ export function CheckoutModal({
291
447
  </div>
292
448
  </div>
293
449
  );
450
+ return typeof document !== 'undefined' ? createPortal(checkout, document.body) : null;
294
451
  }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import ViaViaImage from '@/components/image';
5
+ import ImageModal from '@/components/product-theme-pages/image-modal';
6
+ import styles from './BookingFlowCollage.module.css';
7
+
8
+ export interface DapFlowCollageProps {
9
+ /** Bunny CDN image IDs — first is the tall hero, next four fill the asymmetric grid */
10
+ imageIds: string[];
11
+ altPrefix?: string;
12
+ }
13
+
14
+ /** Hero (left) + four-tile grid (right), same layout as BookingFlowCollage without video */
15
+ export function DapFlowCollage({ imageIds, altPrefix = 'Experience' }: DapFlowCollageProps) {
16
+ const slots = useMemo(() => {
17
+ const raw = imageIds.map((id) => id.trim()).filter(Boolean);
18
+ if (raw.length === 0) return [];
19
+ const out = [...raw];
20
+ while (out.length < 5) {
21
+ out.push(out[0]!);
22
+ }
23
+ return out.slice(0, 5);
24
+ }, [imageIds]);
25
+
26
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
27
+
28
+ const imageItems = useMemo(
29
+ () => slots.map((id, i) => ({ id, alt: `${altPrefix} - ${i + 1}` })),
30
+ [slots, altPrefix]
31
+ );
32
+
33
+ if (slots.length === 0) return null;
34
+
35
+ const heroId = slots[0]!;
36
+ const gridIds = slots.slice(1, 5);
37
+
38
+ return (
39
+ <div className={styles.collage}>
40
+ <div className={styles.videoSlot}>
41
+ <div className={styles.videoWrapper}>
42
+ <button
43
+ type="button"
44
+ className={styles.gridCell}
45
+ onClick={() => setSelectedIndex(0)}
46
+ aria-label="View featured image"
47
+ >
48
+ <ViaViaImage imageId={heroId} alt={`${altPrefix} - featured`} context="GALLERY" />
49
+ </button>
50
+ </div>
51
+ </div>
52
+ <div className={styles.imageGrid}>
53
+ {gridIds.map((id, i) => (
54
+ <button
55
+ key={`${id}-${i}`}
56
+ type="button"
57
+ className={styles.gridCell}
58
+ onClick={() => setSelectedIndex(i + 1)}
59
+ aria-label={`View image ${i + 2}`}
60
+ >
61
+ <ViaViaImage imageId={id} alt={`${altPrefix} - ${i + 2}`} context="GALLERY" />
62
+ </button>
63
+ ))}
64
+ </div>
65
+
66
+ {selectedIndex !== null && imageItems[selectedIndex] && (
67
+ <ImageModal
68
+ selectedImage={imageItems[selectedIndex]!}
69
+ currentIndex={selectedIndex}
70
+ totalImages={imageItems.length}
71
+ images={imageItems}
72
+ onClose={() => setSelectedIndex(null)}
73
+ onNext={() => {
74
+ if (selectedIndex < imageItems.length - 1) {
75
+ setSelectedIndex(selectedIndex + 1);
76
+ }
77
+ }}
78
+ onPrevious={() => {
79
+ if (selectedIndex > 0) {
80
+ setSelectedIndex(selectedIndex - 1);
81
+ }
82
+ }}
83
+ overlayZIndex={10000}
84
+ />
85
+ )}
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { TourDescription } from '@/components/booking/TourDescription';
5
+ import { useLocale, useTranslations } from '@/lib/booking/i18n';
6
+ import { getDapDescription } from '@/lib/dap-descriptions';
7
+ import type { PhotoDapSlug } from '@/lib/photo-dap-config';
8
+
9
+ export function DapTourDescription({
10
+ slug,
11
+ locale: localeProp,
12
+ }: {
13
+ slug: PhotoDapSlug;
14
+ locale?: string;
15
+ }) {
16
+ const { t } = useTranslations();
17
+ const { locale: contextLocale } = useLocale();
18
+ const locale = localeProp ?? contextLocale;
19
+
20
+ const content = useMemo(() => getDapDescription(slug, locale), [slug, locale]);
21
+
22
+ if (!content || (content.paragraphs.length === 0 && (content.sections?.length ?? 0) === 0)) {
23
+ return null;
24
+ }
25
+
26
+ return (
27
+ <TourDescription
28
+ paragraphs={content.paragraphs}
29
+ review={content.review}
30
+ sections={content.sections}
31
+ defaultExpanded={false}
32
+ toggleLabel={t('booking.seeFullAddOnDescription')}
33
+ />
34
+ );
35
+ }