@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,2662 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
4
+ import { format, addWeeks, parseISO, isBefore, isAfter, startOfDay, endOfDay } from 'date-fns';
5
+ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
6
+ import {
7
+ getAvailabilities,
8
+ createReservation,
9
+ cancelReservation,
10
+ cancelReservationBestEffort,
11
+ createPaymentIntent,
12
+ confirmFreeBooking,
13
+ confirmBookingWithoutPayment,
14
+ confirmPartnerBookingWithoutPayment,
15
+ getAddOns,
16
+ validatePromoCode,
17
+ getPromoDiscount,
18
+ type Product,
19
+ type Availability,
20
+ type ItineraryDisplayStep,
21
+ type AddOn,
22
+ ItineraryStepType,
23
+ isInsufficientCapacityReserveError,
24
+ describePrivateShuttleCapacityConflictMessage,
25
+ reportReserveCapacityConflictClientContext,
26
+ } from '@/lib/booking-api';
27
+ import {
28
+ EARLIEST_AVAILABILITY_DATE,
29
+ LATEST_AVAILABILITY_DATE,
30
+ INITIAL_FETCH_WEEKS,
31
+ } from '@/lib/booking-constants';
32
+ import { formatCurrencyAmount } from '@/lib/currency';
33
+ import { formatBookingRefForDisplay } from '@/lib/booking-ref';
34
+ import { buildCheckoutBreakdown } from '@/lib/booking/checkout-breakdown';
35
+ import type { PricingConfig, PrecomputedPricesByCategory } from '@/lib/booking-api';
36
+ import { Calendar } from './Calendar';
37
+ import { PickupLocationSelector } from './PickupLocationSelector';
38
+ import { useTranslations, useLocale } from '@/lib/booking/i18n';
39
+ import { type Currency } from './CurrencySwitcher';
40
+ import { useCompanyTimezone } from '@/contexts/CompanyContext';
41
+ import { useBookingApp } from '@/contexts/BookingAppContext';
42
+ import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '@/contexts/AvailabilitiesCacheContext';
43
+ import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
44
+ import { CancellationPolicySelector } from './CancellationPolicySelector';
45
+ import { PriceSummary } from './PriceSummary';
46
+ import { TermsAcceptance } from './TermsAcceptance';
47
+ import { ItineraryBuilder } from './ItineraryBuilder';
48
+ import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
49
+ import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
50
+ import { BookingFlowCollage } from './BookingFlowCollage';
51
+ import { TourDescription } from './TourDescription';
52
+ import { getProductByIdOrSlug } from '@/lib/products-config';
53
+ import { getProducts } from '@/constants/products';
54
+ import defaultStrings from '@/strings';
55
+ import { trackViewItem } from '@/lib/analytics';
56
+ import styles from './PrivateShuttleBookingFlow.module.css';
57
+ import {
58
+ buildBookingSourceContext,
59
+ inferClientBookingSourceFromProductIds,
60
+ type BookingSourceMetadata,
61
+ } from '@/lib/booking/source-metadata';
62
+ import type { BookingFlowUiOptions } from './booking-flow-ui';
63
+ import { BOOKING_FLOW_ABANDON_EVENT } from '@/providers/booking-dialog-provider';
64
+
65
+ interface PrivateShuttleBookingFlowProps {
66
+ product: Product;
67
+ productId?: string;
68
+ onBack: () => void;
69
+ currency: Currency;
70
+ /** Scroll container ref (e.g. from BookingDialog) - scroll happens inside this element, not window */
71
+ contentRef?: React.RefObject<HTMLDivElement | null>;
72
+ onSuccess?: (data: { reservationReference: string; bookingReference?: string }) => void;
73
+ /** Optional pickup location IDs to prioritize/highlight in selector (e.g. partner-preferred). */
74
+ highlightedPickupLocationIds?: string[];
75
+ mode?: 'standard' | 'change';
76
+ onPricePreviewChange?: (preview: {
77
+ subtotal: number;
78
+ tax: number;
79
+ total: number;
80
+ currency: Currency;
81
+ } | null) => void;
82
+ /** Partner / embed-only tweaks; omit for default website behavior. */
83
+ flowUi?: BookingFlowUiOptions;
84
+ /** Explicit reserve/checkout source metadata (compose at call site, e.g. URL + portal). */
85
+ bookingSourceAttribution: Partial<BookingSourceMetadata>;
86
+ /** Dedicated partner portal app (`booking.*`): persist reserve `source` as PARTNER_PORTAL. */
87
+ partnerPortalBooking?: boolean;
88
+ /** When set (e.g. partner portal), get-availabilities requests this pricing profile from the API. */
89
+ availabilityPricingProfileId?: string | null;
90
+ /** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
91
+ availabilityCancellationPolicyProfileId?: string | null;
92
+ initialValues?: {
93
+ bookingReference?: string | null;
94
+ dateTime?: string | null;
95
+ pickupLocationId?: string | null;
96
+ customPickupAddress?: string | null;
97
+ passengers?: number | null;
98
+ childSafetySeatsCount?: number | null;
99
+ foodRestrictions?: string | null;
100
+ additionalHoursCount?: number | null;
101
+ specialRequest?: string | null;
102
+ notes?: string | null;
103
+ };
104
+ }
105
+
106
+ const RESOURCE_CAPACITY = 13;
107
+ const ADDITIONAL_HOUR_PRICE = 170;
108
+ const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
109
+
110
+ function parseAvailabilityDateTime(value: string): Date {
111
+ // If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
112
+ const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
113
+ return parseISO(hasExplicitOffset ? value : `${value}Z`);
114
+ }
115
+
116
+ function findMergedPrivateShuttleAvailability(
117
+ merged: Availability[],
118
+ sel: Availability | null,
119
+ optionId: string | null,
120
+ selectedDateStr: string | null,
121
+ tz: string
122
+ ): Availability | undefined {
123
+ if (!sel || !optionId || !selectedDateStr) return undefined;
124
+ const oidMatches = (a: Availability) => (a.productOptionId ?? a.productId) === optionId;
125
+ const availId = sel.availabilityId?.trim();
126
+ if (availId) {
127
+ const hit = merged.find((a) => a.availabilityId === availId && oidMatches(a));
128
+ if (hit) return hit;
129
+ }
130
+ const onDay = merged.filter((a) => {
131
+ if (!oidMatches(a)) return false;
132
+ const day = DATE_ONLY_REGEX.test(a.dateTime)
133
+ ? a.dateTime
134
+ : formatInTimeZone(parseAvailabilityDateTime(a.dateTime), tz, 'yyyy-MM-dd');
135
+ return day === selectedDateStr;
136
+ });
137
+ return onDay[0];
138
+ }
139
+
140
+ export function PrivateShuttleBookingFlow({
141
+ product,
142
+ productId,
143
+ onBack: _onBack,
144
+ currency,
145
+ contentRef,
146
+ onSuccess,
147
+ highlightedPickupLocationIds,
148
+ mode = 'standard',
149
+ onPricePreviewChange,
150
+ flowUi,
151
+ bookingSourceAttribution,
152
+ partnerPortalBooking = false,
153
+ availabilityPricingProfileId,
154
+ availabilityCancellationPolicyProfileId,
155
+ initialValues,
156
+ }: PrivateShuttleBookingFlowProps) {
157
+ const { t } = useTranslations();
158
+ const { locale } = useLocale();
159
+ const companyTimezone = useCompanyTimezone();
160
+ const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
161
+ const cancellationPolicyProfileIdForAvailabilities =
162
+ (availabilityCancellationPolicyProfileId ?? '').trim() || null;
163
+ const {
164
+ permissions,
165
+ onShowManage,
166
+ getSuccessUrl,
167
+ suppressCalendarDateScroll,
168
+ mode: bookingAppMode,
169
+ } = useBookingApp();
170
+ const availabilitiesCache = useAvailabilitiesCache();
171
+ const isAdmin = permissions.viewerRole === 'admin';
172
+
173
+ const [availabilities, setAvailabilities] = useState<Availability[]>([]);
174
+ const [selectedDate, setSelectedDate] = useState<string>('');
175
+ const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
176
+ const [selectedOption, setSelectedOption] = useState<string>('');
177
+ const [selectedStartTime, setSelectedStartTime] = useState<string>('');
178
+ const [isCustomTimeMode, setIsCustomTimeMode] = useState(false);
179
+ const [passengerCount, setPassengerCount] = useState<number>(1);
180
+ const [email, setEmail] = useState('');
181
+ const [firstName, setFirstName] = useState('');
182
+ const [lastName, setLastName] = useState('');
183
+ const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
184
+ const hasAutoSelectedPartnerDateRef = useRef(false);
185
+ const hasAutoSelectedPartnerPickupRef = useRef(false);
186
+ const handleDateSelectRef = useRef<(date: string) => void>(() => {});
187
+ const [customPickupAddress, setCustomPickupAddress] = useState<string | null>(null);
188
+ const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
189
+ const [draftItineraryDestinations, setDraftItineraryDestinations] = useState<string[]>([]);
190
+ const [draftItineraryPlanningNotes, setDraftItineraryPlanningNotes] = useState('');
191
+ const [childSafetySeatsCount, setChildSafetySeatsCount] = useState(0);
192
+ const [foodRestrictions, setFoodRestrictions] = useState('');
193
+ const [addOnSelections, setAddOnSelections] = useState<
194
+ Array<{ addOnId: string; variantId?: string; quantity?: number }>
195
+ >([]);
196
+ const [addOns, setAddOns] = useState<AddOn[]>([]);
197
+ const [loading, setLoading] = useState(false);
198
+ const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
199
+ const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
200
+ const [error, setError] = useState('');
201
+ const [showCheckoutModal, setShowCheckoutModal] = useState(false);
202
+ /** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
203
+ const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
204
+ /** True while Stripe is confirming payment/redirecting; skip unload cancellation during this window. */
205
+ const paymentSubmitInFlightRef = useRef(false);
206
+ const [termsAccepted, setTermsAccepted] = useState(false);
207
+ const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
208
+ const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
209
+ const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
210
+ const [checkoutModalData, setCheckoutModalData] = useState<{
211
+ reservationReference: string;
212
+ reservationExpiration?: string;
213
+ customerLastName?: string;
214
+ bookingDate?: string;
215
+ ticketLines: CheckoutModalLineItem[];
216
+ feeLineItems: { name: string; totalAmount: number; description?: string }[];
217
+ returnPriceAdjustment: number;
218
+ subtotal: number;
219
+ tax: number;
220
+ total: number;
221
+ totalQuantity: number;
222
+ isTaxIncludedInPrice: boolean;
223
+ taxRate: number;
224
+ cancellationPolicyFee?: number;
225
+ cancellationPolicyLabel?: string;
226
+ promoDiscountAmount?: number;
227
+ discountLabel?: string | null;
228
+ isDepositPayment?: boolean;
229
+ balanceChargeDaysBefore?: number;
230
+ } | null>(null);
231
+ const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
232
+ const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
233
+ const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
234
+ const [promoCodeInput, setPromoCodeInput] = useState('');
235
+ const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
236
+ const [promoCodeError, setPromoCodeError] = useState('');
237
+ const [promoCodeValidating, setPromoCodeValidating] = useState(false);
238
+ const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
239
+ const [isGiftCard, setIsGiftCard] = useState(false);
240
+ const [isVoucher, setIsVoucher] = useState(false);
241
+ const [additionalHoursCount, setAdditionalHoursCount] = useState(0);
242
+ const [isSpecialRequestMode, setIsSpecialRequestMode] = useState(false);
243
+ const [specialRequestInputValue, setSpecialRequestInputValue] = useState('');
244
+ const [adminChoiceData, setAdminChoiceData] = useState<{
245
+ reservationReference: string;
246
+ reservationExpiration?: string;
247
+ checkoutBreakdown: {
248
+ lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>;
249
+ totalAmount: number;
250
+ currency: string;
251
+ };
252
+ totalAmount: number;
253
+ datePart: string;
254
+ timePart: string;
255
+ availabilityProductOptionId: string;
256
+ itineraryDisplay?: ItineraryDisplayStep[] | null;
257
+ clientSecret: string;
258
+ ticketLinesForModal: CheckoutModalLineItem[];
259
+ feeLineItems: { name: string; totalAmount: number }[];
260
+ cancellationPolicyFee: number;
261
+ cancellationPolicyLabel?: string;
262
+ subtotal: number;
263
+ tax: number;
264
+ totalQuantity: number;
265
+ isTaxIncludedInPrice: boolean;
266
+ taxRate: number;
267
+ promoDiscountAmount: number;
268
+ discountLabel?: string | null;
269
+ } | null>(null);
270
+ const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
271
+ const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(null);
272
+ const [precomputedPrices, setPrecomputedPrices] = useState<PrecomputedPricesByCategory | null>(null);
273
+ const [resourcePriceByCurrency, setResourcePriceByCurrency] = useState<Record<string, number> | null>(null);
274
+ const [resourcePriceByOption, setResourcePriceByOption] = useState<
275
+ Record<string, Record<string, number>> | null
276
+ >(null);
277
+ const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
278
+ const lastValidatedInputRef = useRef<string | null>(null);
279
+ const fetchingRef = useRef(false);
280
+ const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]);
281
+ const pricingConfigSetRef = useRef(false);
282
+
283
+ useEffect(() => {
284
+ setPartnerAttributionConfirmed(false);
285
+ }, [flowUi?.partnerAttributionSummary, flowUi?.partnerAttributionConfirmLabel]);
286
+
287
+ const activeOptions = useMemo(
288
+ () => product.options?.filter((opt) => opt.status === 'ACTIVE') || [],
289
+ [product.options]
290
+ );
291
+
292
+ // Fire view_item when product is first displayed
293
+ const hasFiredViewItem = useRef(false);
294
+ useEffect(() => {
295
+ if (!hasFiredViewItem.current && product) {
296
+ hasFiredViewItem.current = true;
297
+ const id = productId || product.productId;
298
+ const price = product.minPriceByCurrency?.[currency] ?? 0;
299
+ trackViewItem(id, product.name, price, currency);
300
+ }
301
+ }, [product, productId, currency]);
302
+
303
+ const activeOptionIdsKey = useMemo(
304
+ () => activeOptions.map((opt) => opt.optionId).sort().join(','),
305
+ [activeOptions]
306
+ );
307
+ const selectedOptionConfig = activeOptions.find((opt) => opt.optionId === selectedOption);
308
+ const privateShuttleConfig = selectedOptionConfig?.privateShuttleConfig;
309
+ const selectedPickupLocation = useMemo(
310
+ () =>
311
+ pickupLocationId ? product.pickupLocations?.find((loc) => loc.id === pickupLocationId) : null,
312
+ [pickupLocationId, product.pickupLocations]
313
+ );
314
+
315
+ const needsFetch = useCallback((start: Date, end: Date) => {
316
+ if (fetchedRangesRef.current.length === 0) return true;
317
+ return !fetchedRangesRef.current.some(
318
+ (range) =>
319
+ range.start.getTime() <= start.getTime() && range.end.getTime() >= end.getTime()
320
+ );
321
+ }, []);
322
+
323
+ const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
324
+ if (!visibleRange || !product.productId) return [];
325
+
326
+ const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
327
+ const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
328
+ const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
329
+ ? startOfEarliestDay
330
+ : visibleRange.start;
331
+ let clampedEnd = isAfter(visibleRange.end, endOfLatestDay) ? endOfLatestDay : visibleRange.end;
332
+ if (selectedDate) {
333
+ try {
334
+ const selectedDateObj = parseISO(selectedDate);
335
+ if (isAfter(selectedDateObj, clampedEnd)) clampedEnd = selectedDateObj;
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+ }
340
+
341
+ const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
342
+ const endDate = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
343
+ const result = await getAvailabilities(product.productId, startDate, endDate, {
344
+ allOptions: true,
345
+ promoCode: appliedPromoCode || undefined,
346
+ ...(pricingProfileIdForAvailabilities
347
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
348
+ : {}),
349
+ ...(cancellationPolicyProfileIdForAvailabilities
350
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
351
+ : {}),
352
+ });
353
+
354
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
355
+ setPricingConfig(result.pricingConfig);
356
+ pricingConfigSetRef.current = true;
357
+ }
358
+ if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
359
+ if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
360
+ if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
361
+
362
+ let mergedAvailabilities: Availability[] = [];
363
+ setAvailabilities((prev) => {
364
+ const existingMap = new Map(
365
+ prev.map((avail) => [`${avail.dateTime}-${avail.productId || avail.productOptionId}`, avail])
366
+ );
367
+ result.availabilities.forEach((avail) => {
368
+ existingMap.set(`${avail.dateTime}-${avail.productId || avail.productOptionId}`, avail);
369
+ });
370
+ mergedAvailabilities = Array.from(existingMap.values());
371
+ return mergedAvailabilities;
372
+ });
373
+
374
+ fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
375
+ fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
376
+ const mergedRanges: Array<{ start: Date; end: Date }> = [];
377
+ for (const r of fetchedRangesRef.current) {
378
+ if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].end < r.start) {
379
+ mergedRanges.push({ start: r.start, end: r.end });
380
+ } else {
381
+ mergedRanges[mergedRanges.length - 1].end =
382
+ r.end > mergedRanges[mergedRanges.length - 1].end
383
+ ? r.end
384
+ : mergedRanges[mergedRanges.length - 1].end;
385
+ }
386
+ }
387
+ fetchedRangesRef.current = mergedRanges;
388
+
389
+ const cacheKey = availabilitiesCache
390
+ ? buildAvailabilitiesCacheKey(
391
+ product.productId,
392
+ activeOptionIdsKey,
393
+ appliedPromoCode,
394
+ pricingProfileIdForAvailabilities,
395
+ )
396
+ : null;
397
+ if (cacheKey && availabilitiesCache) {
398
+ availabilitiesCache.merge(cacheKey, {
399
+ fetchedRanges: mergedRanges,
400
+ availabilities: mergedAvailabilities,
401
+ pricingConfig: result.pricingConfig ?? null,
402
+ precomputedPrices: result.precomputedPrices ?? null,
403
+ resourcePriceByCurrency: result.resourcePriceByCurrency ?? null,
404
+ resourcePriceByOption: result.resourcePriceByOption ?? null,
405
+ });
406
+ }
407
+
408
+ return mergedAvailabilities;
409
+ }, [
410
+ visibleRange,
411
+ product.productId,
412
+ selectedDate,
413
+ companyTimezone,
414
+ appliedPromoCode,
415
+ pricingProfileIdForAvailabilities,
416
+ cancellationPolicyProfileIdForAvailabilities,
417
+ activeOptionIdsKey,
418
+ availabilitiesCache,
419
+ ]);
420
+
421
+ useEffect(() => {
422
+ if (!visibleRange) {
423
+ setVisibleRange({
424
+ start: EARLIEST_AVAILABILITY_DATE,
425
+ end: addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS),
426
+ });
427
+ }
428
+ }, [visibleRange]);
429
+
430
+ useEffect(() => {
431
+ if (selectedOption && !product.companyId) return;
432
+ if (!selectedOption) {
433
+ setAddOns([]);
434
+ return;
435
+ }
436
+ getAddOns(product.companyId!, {
437
+ productOptionId: selectedOption,
438
+ preCheckout: true,
439
+ })
440
+ .then(setAddOns)
441
+ .catch(() => setAddOns([]));
442
+ }, [selectedOption, product.companyId]);
443
+
444
+ useEffect(() => {
445
+ if (!draftItineraryDestinations.includes('emerald_lake')) {
446
+ setAddOnSelections((prev) => prev.filter((s) => s.addOnId !== 'addon_el_lunch'));
447
+ }
448
+ }, [draftItineraryDestinations]);
449
+
450
+ useEffect(() => {
451
+ fetchedRangesRef.current = [];
452
+ }, [
453
+ appliedPromoCode,
454
+ pricingProfileIdForAvailabilities,
455
+ cancellationPolicyProfileIdForAvailabilities,
456
+ ]);
457
+
458
+ // Memoized callback for visible range changes - only update if range changed significantly (match BookingFlow)
459
+ const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
460
+ const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
461
+ const lastRange = lastVisibleRangeRef.current;
462
+ const rangeChanged =
463
+ !lastRange ||
464
+ Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
465
+ Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
466
+ if (rangeChanged) {
467
+ lastVisibleRangeRef.current = { start, end };
468
+ setVisibleRange({ start, end });
469
+ }
470
+ }, []);
471
+
472
+ useEffect(() => {
473
+ if (activeOptions.length === 0) {
474
+ setError('No active product options available');
475
+ setLoadingAvailabilities(false);
476
+ return;
477
+ }
478
+ if (!visibleRange) return;
479
+
480
+ async function fetchAvailabilities() {
481
+ if (fetchingRef.current) return;
482
+ const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
483
+ const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
484
+ const clampedStart = isBefore(visibleRange!.start, startOfEarliestDay)
485
+ ? startOfEarliestDay
486
+ : visibleRange!.start;
487
+ let clampedEnd = isAfter(visibleRange!.end, endOfLatestDay)
488
+ ? endOfLatestDay
489
+ : visibleRange!.end;
490
+ if (selectedDate) {
491
+ try {
492
+ const selectedDateObj = parseISO(selectedDate);
493
+ if (isAfter(selectedDateObj, clampedEnd)) clampedEnd = selectedDateObj;
494
+ } catch {
495
+ /* ignore */
496
+ }
497
+ }
498
+ // Check cache first - avoid refetch when reopening same product (match BookingFlow)
499
+ const cacheKey = availabilitiesCache
500
+ ? buildAvailabilitiesCacheKey(
501
+ product.productId,
502
+ activeOptionIdsKey,
503
+ appliedPromoCode,
504
+ pricingProfileIdForAvailabilities,
505
+ )
506
+ : null;
507
+ const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
508
+ if (cached && cached.availabilities.length > 0) {
509
+ const cacheCoversRange = cached.fetchedRanges.some(
510
+ (r) =>
511
+ r.start.getTime() <= clampedStart.getTime() && r.end.getTime() >= clampedEnd.getTime()
512
+ );
513
+ const isStale = availabilitiesCache?.isStale(cached) ?? false;
514
+ if (cacheCoversRange) {
515
+ setAvailabilities(cached.availabilities);
516
+ if (cached.pricingConfig) {
517
+ setPricingConfig(cached.pricingConfig);
518
+ pricingConfigSetRef.current = true;
519
+ }
520
+ if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
521
+ if (cached.resourcePriceByCurrency) setResourcePriceByCurrency(cached.resourcePriceByCurrency);
522
+ if (cached.resourcePriceByOption) setResourcePriceByOption(cached.resourcePriceByOption);
523
+ setLoadingAvailabilities(false);
524
+ setIsFetchingMoreAvailabilities(false);
525
+ if (!isStale) {
526
+ fetchedRangesRef.current = [...cached.fetchedRanges];
527
+ fetchingRef.current = false;
528
+ return;
529
+ }
530
+ }
531
+ // Partial cache: show cached data immediately, then fetch missing range below
532
+ setAvailabilities(cached.availabilities);
533
+ if (cached.pricingConfig) {
534
+ setPricingConfig(cached.pricingConfig);
535
+ pricingConfigSetRef.current = true;
536
+ }
537
+ if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
538
+ if (cached.resourcePriceByCurrency) setResourcePriceByCurrency(cached.resourcePriceByCurrency);
539
+ if (cached.resourcePriceByOption) setResourcePriceByOption(cached.resourcePriceByOption);
540
+ fetchedRangesRef.current = [...cached.fetchedRanges];
541
+ setLoadingAvailabilities(false);
542
+ }
543
+
544
+ if (!needsFetch(clampedStart, clampedEnd)) {
545
+ setLoadingAvailabilities(false);
546
+ setIsFetchingMoreAvailabilities(false);
547
+ fetchingRef.current = false;
548
+ return;
549
+ }
550
+ const hasPartialCache = cached && cached.availabilities.length > 0;
551
+ fetchingRef.current = true;
552
+ if (!hasPartialCache) setLoadingAvailabilities(true);
553
+ else setIsFetchingMoreAvailabilities(true);
554
+ try {
555
+ const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
556
+ const endDate = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
557
+ const result = await getAvailabilities(product.productId, startDate, endDate, {
558
+ allOptions: true,
559
+ promoCode: appliedPromoCode || undefined,
560
+ ...(pricingProfileIdForAvailabilities
561
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
562
+ : {}),
563
+ ...(cancellationPolicyProfileIdForAvailabilities
564
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
565
+ : {}),
566
+ });
567
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
568
+ setPricingConfig(result.pricingConfig);
569
+ pricingConfigSetRef.current = true;
570
+ }
571
+ if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
572
+ if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
573
+ if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
574
+ // Merge with existing when we had partial cache (match BookingFlow)
575
+ const mergedAvailabilities = (() => {
576
+ const existing = hasPartialCache && cached ? cached.availabilities : [];
577
+ if (existing.length === 0) return result.availabilities;
578
+ const existingMap = new Map(
579
+ existing.map((avail) => [
580
+ `${avail.dateTime}-${avail.productId || avail.productOptionId}`,
581
+ avail,
582
+ ])
583
+ );
584
+ result.availabilities.forEach((avail) => {
585
+ const key = `${avail.dateTime}-${avail.productId || avail.productOptionId}`;
586
+ existingMap.set(key, avail);
587
+ });
588
+ return Array.from(existingMap.values());
589
+ })();
590
+ setAvailabilities(mergedAvailabilities);
591
+ fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
592
+ fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
593
+ const merged: Array<{ start: Date; end: Date }> = [];
594
+ for (const r of fetchedRangesRef.current) {
595
+ if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
596
+ merged.push({ start: r.start, end: r.end });
597
+ } else {
598
+ merged[merged.length - 1].end =
599
+ r.end > merged[merged.length - 1].end ? r.end : merged[merged.length - 1].end;
600
+ }
601
+ }
602
+ fetchedRangesRef.current = merged;
603
+ if (passengerCount === 0) setPassengerCount(1);
604
+
605
+ // Update cache for instant load when reopening same product (match BookingFlow)
606
+ if (cacheKey && availabilitiesCache) {
607
+ availabilitiesCache.merge(cacheKey, {
608
+ fetchedRanges: fetchedRangesRef.current,
609
+ availabilities: mergedAvailabilities,
610
+ pricingConfig: result.pricingConfig ?? null,
611
+ precomputedPrices: result.precomputedPrices ?? null,
612
+ resourcePriceByCurrency: result.resourcePriceByCurrency ?? null,
613
+ resourcePriceByOption: result.resourcePriceByOption ?? null,
614
+ });
615
+ }
616
+ } catch (err) {
617
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
618
+ } finally {
619
+ setLoadingAvailabilities(false);
620
+ setIsFetchingMoreAvailabilities(false);
621
+ fetchingRef.current = false;
622
+ }
623
+ }
624
+ fetchAvailabilities();
625
+ // passengerCount intentionally excluded - we only use it for init (setPassengerCount(1)), not for fetch
626
+ // eslint-disable-next-line react-hooks/exhaustive-deps
627
+ }, [
628
+ visibleRange,
629
+ activeOptions,
630
+ activeOptionIdsKey,
631
+ selectedDate,
632
+ product.productId,
633
+ appliedPromoCode,
634
+ pricingProfileIdForAvailabilities,
635
+ cancellationPolicyProfileIdForAvailabilities,
636
+ needsFetch,
637
+ availabilitiesCache,
638
+ ]);
639
+
640
+ // Group availabilities by date (in company timezone - MDT). Availabilities from API are in UTC.
641
+ const availabilitiesByDate = useMemo(() => {
642
+ const grouped = availabilities.reduce(
643
+ (acc, avail) => {
644
+ const dateInCompanyTz = DATE_ONLY_REGEX.test(avail.dateTime)
645
+ ? avail.dateTime
646
+ : formatInTimeZone(parseAvailabilityDateTime(avail.dateTime), companyTimezone, 'yyyy-MM-dd');
647
+ if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
648
+ acc[dateInCompanyTz].push(avail);
649
+ return acc;
650
+ },
651
+ {} as Record<string, Availability[]>
652
+ );
653
+ // Sort availabilities within each date by time (match BookingFlow)
654
+ const sorted: Record<string, Availability[]> = {};
655
+ Object.keys(grouped).forEach((date) => {
656
+ sorted[date] = [...grouped[date]].sort((a, b) => {
657
+ const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
658
+ const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
659
+ return timeA - timeB;
660
+ });
661
+ });
662
+ return sorted;
663
+ }, [availabilities, companyTimezone]);
664
+
665
+ const optionsAvailableForSelectedDate = useMemo(() => {
666
+ if (!selectedDate) return [];
667
+ const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
668
+ const optionIds = new Set(
669
+ dateAvailabilities.map((a) => a.productId || a.productOptionId).filter(Boolean)
670
+ );
671
+ return activeOptions.filter((opt) => optionIds.has(opt.optionId));
672
+ }, [selectedDate, availabilitiesByDate, activeOptions]);
673
+
674
+ const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
675
+
676
+ const earliestAvailabilityDate = useMemo(() => {
677
+ if (dates.length === 0) return EARLIEST_AVAILABILITY_DATE;
678
+ // Build date in company timezone at noon to avoid cross-timezone day shifts.
679
+ return fromZonedTime(parseISO(`${dates[0]}T12:00:00`), companyTimezone);
680
+ }, [dates, companyTimezone]);
681
+
682
+ const suggestedStartTimes =
683
+ selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
684
+ const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
685
+ const maxVacancies = selectedAvailability?.vacancies || 0;
686
+ const isSpecialRequest = passengerCount > maxVacancies;
687
+ const billableResourceCount = isSpecialRequest
688
+ ? Math.max(1, Math.ceil(maxVacancies / RESOURCE_CAPACITY))
689
+ : resourceCount;
690
+
691
+ const getOptionPrice = useCallback(
692
+ (optionId: string) => {
693
+ const dateAvailabilities = selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
694
+ const avail = dateAvailabilities.find(
695
+ (a) => (a.productId || a.productOptionId) === optionId
696
+ );
697
+ if (avail?.rates) {
698
+ const resourceRate = avail.rates.find(
699
+ (r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE'
700
+ );
701
+ if (resourceRate?.priceByCurrency?.[currency] != null)
702
+ return resourceRate.priceByCurrency[currency];
703
+ if (currency === 'CAD' && resourceRate?.price != null) return resourceRate.price;
704
+ }
705
+ if (resourcePriceByOption?.[optionId]?.[currency] != null)
706
+ return resourcePriceByOption[optionId][currency];
707
+ if (resourcePriceByCurrency?.[currency] != null) return resourcePriceByCurrency[currency];
708
+ const opt = activeOptions.find((o) => o.optionId === optionId);
709
+ return opt?.pricing?.['RESOURCE'] ?? 0;
710
+ },
711
+ [
712
+ selectedDate,
713
+ availabilitiesByDate,
714
+ resourcePriceByOption,
715
+ resourcePriceByCurrency,
716
+ currency,
717
+ activeOptions,
718
+ ]
719
+ );
720
+
721
+ const resourcePrice = useMemo(() => {
722
+ if (selectedAvailability?.rates) {
723
+ const resourceRate = selectedAvailability.rates.find(
724
+ (r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE'
725
+ );
726
+ if (resourceRate) {
727
+ const fromRate =
728
+ resourceRate.priceByCurrency?.[currency] ??
729
+ (currency === 'CAD' ? resourceRate.price : undefined);
730
+ if (fromRate != null) return fromRate;
731
+ }
732
+ }
733
+ if (selectedOption && resourcePriceByOption?.[selectedOption]?.[currency] != null)
734
+ return resourcePriceByOption[selectedOption][currency];
735
+ if (resourcePriceByCurrency?.[currency] != null) return resourcePriceByCurrency[currency];
736
+ if (precomputedPrices?.['RESOURCE']?.[currency] != null)
737
+ return precomputedPrices['RESOURCE'][currency];
738
+ return selectedOptionConfig?.pricing?.['RESOURCE'] ?? 0;
739
+ }, [
740
+ selectedAvailability,
741
+ resourcePriceByOption,
742
+ resourcePriceByCurrency,
743
+ precomputedPrices,
744
+ selectedOption,
745
+ selectedOptionConfig,
746
+ currency,
747
+ ]);
748
+
749
+ const basePrice = resourcePrice * resourceCount;
750
+ const isTaxIncludedInPrice = (pricingConfig?.currenciesWithTaxIncluded ?? []).includes(currency);
751
+ const taxRate = pricingConfig?.taxRate ?? 0.05;
752
+ const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find(
753
+ (p) => p.id === cancellationPolicyId
754
+ );
755
+ const cancellationPolicyFee = selectedCancellationPolicy
756
+ ? (selectedCancellationPolicy.feeByCurrency[currency] ?? 0)
757
+ : 0;
758
+
759
+ const addOnTotal = useMemo(() => {
760
+ let sum = 0;
761
+ for (const sel of addOnSelections) {
762
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
763
+ if (!addOn) continue;
764
+ const base = addOn.price ?? 0;
765
+ const hasVariant =
766
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
767
+ sel.variantId;
768
+ const adj = hasVariant
769
+ ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
770
+ : 0;
771
+ sum += (base + adj) * (sel.quantity ?? 1);
772
+ }
773
+ return sum;
774
+ }, [addOnSelections, addOns]);
775
+
776
+ const additionalHoursAmount = (isAdmin ? additionalHoursCount : 0) * ADDITIONAL_HOUR_PRICE;
777
+ const subtotal = basePrice + addOnTotal + additionalHoursAmount;
778
+ const effectivePromoDiscountAmount = promoDiscountAmount > 0 ? promoDiscountAmount : 0;
779
+ const taxAmount = isTaxIncludedInPrice ? 0 : subtotal * taxRate;
780
+ const effectiveTaxAmount =
781
+ effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
782
+ ? (isTaxIncludedInPrice ? 0 : (subtotal - effectivePromoDiscountAmount) * taxRate)
783
+ : taxAmount;
784
+ const totalPrice = subtotal + effectiveTaxAmount - effectivePromoDiscountAmount;
785
+
786
+ useEffect(() => {
787
+ if (!onPricePreviewChange) return;
788
+ if (!selectedOption || !selectedStartTime || passengerCount <= 0) {
789
+ onPricePreviewChange(null);
790
+ return;
791
+ }
792
+ onPricePreviewChange({
793
+ subtotal,
794
+ tax: effectiveTaxAmount,
795
+ total: totalPrice,
796
+ currency,
797
+ });
798
+ }, [
799
+ onPricePreviewChange,
800
+ selectedOption,
801
+ selectedStartTime,
802
+ passengerCount,
803
+ subtotal,
804
+ effectiveTaxAmount,
805
+ totalPrice,
806
+ currency,
807
+ ]);
808
+
809
+ const depositInfo = useMemo(() => {
810
+ if (!privateShuttleConfig?.depositConfig || totalPrice <= 0) return null;
811
+ const { depositConfig } = privateShuttleConfig;
812
+ let depositAmount = 0;
813
+ if (depositConfig.percentage != null) {
814
+ // Support both: 30 = 30%, or 0.3 = 30% (decimal from API)
815
+ const pct = depositConfig.percentage;
816
+ depositAmount = pct <= 1 ? totalPrice * pct : (totalPrice * pct) / 100;
817
+ } else if (depositConfig.fixedAmount != null) {
818
+ depositAmount = depositConfig.fixedAmount;
819
+ }
820
+ if (depositAmount <= 0) return null;
821
+ return {
822
+ depositAmount,
823
+ balanceAmount: totalPrice - depositAmount,
824
+ totalPrice,
825
+ };
826
+ }, [privateShuttleConfig, totalPrice]);
827
+
828
+ const hasOngoingDiscount = useMemo(
829
+ () =>
830
+ selectedAvailability?.rates?.some((r) =>
831
+ (r.appliedAdjustments ?? r.applied_adjustments ?? []).some(
832
+ (a) => (a.type ?? '').toLowerCase() === 'deal'
833
+ )
834
+ ) ?? false,
835
+ [selectedAvailability]
836
+ );
837
+
838
+ useEffect(() => {
839
+ if (
840
+ !appliedPromoCode ||
841
+ !selectedOption ||
842
+ !selectedDate ||
843
+ !selectedStartTime ||
844
+ resourceCount === 0
845
+ ) {
846
+ setPromoDiscountAmount(0);
847
+ setIsGiftCard(false);
848
+ setIsVoucher(false);
849
+ return;
850
+ }
851
+ const companyId = product.companyId;
852
+ if (!companyId) return;
853
+ let cancelled = false;
854
+ getPromoDiscount(
855
+ appliedPromoCode,
856
+ companyId,
857
+ product.productId,
858
+ selectedOption,
859
+ currency,
860
+ [{ category: 'RESOURCE', qty: resourceCount }],
861
+ selectedDate,
862
+ subtotal
863
+ )
864
+ .then((res) => {
865
+ if (!cancelled) {
866
+ setPromoDiscountAmount(res.discount ?? 0);
867
+ setIsGiftCard(res.isGiftCard ?? false);
868
+ setIsVoucher(res.isVoucher ?? false);
869
+ }
870
+ })
871
+ .catch(() => {
872
+ if (!cancelled) {
873
+ setPromoDiscountAmount(0);
874
+ setIsGiftCard(false);
875
+ setIsVoucher(false);
876
+ }
877
+ });
878
+ return () => {
879
+ cancelled = true;
880
+ };
881
+ }, [
882
+ appliedPromoCode,
883
+ selectedOption,
884
+ selectedDate,
885
+ selectedStartTime,
886
+ resourceCount,
887
+ product.companyId,
888
+ product.productId,
889
+ currency,
890
+ subtotal,
891
+ ]);
892
+
893
+ const handleDateSelect = (date: string) => {
894
+ setSelectedDate(date);
895
+ setSelectedOption('');
896
+ setSelectedAvailability(null);
897
+ setSelectedStartTime('');
898
+ setIsCustomTimeMode(false);
899
+ setDraftItineraryDestinations([]);
900
+ setDraftItineraryPlanningNotes('');
901
+ setError('');
902
+ if (!suppressCalendarDateScroll) {
903
+ // Scroll so calendar is almost at top and user sees the rest of the booking flow (inside dialog, not window)
904
+ setTimeout(() => {
905
+ contentRef?.current?.scrollBy({ top: 400, behavior: 'smooth' });
906
+ }, 100);
907
+ }
908
+ };
909
+
910
+ handleDateSelectRef.current = handleDateSelect;
911
+
912
+ useEffect(() => {
913
+ if (flowUi?.autoSelectFirstAvailableDate !== true) return;
914
+ if (mode === 'change') return;
915
+ if (initialValues?.dateTime?.trim()) return;
916
+ if (selectedDate !== '') return;
917
+ if (dates.length === 0) return;
918
+ if (hasAutoSelectedPartnerDateRef.current) return;
919
+
920
+ const first = isAdmin
921
+ ? dates[0]
922
+ : dates.find((d) =>
923
+ (availabilitiesByDate[d] ?? []).some((a) => (a.vacancies ?? 0) > 0),
924
+ );
925
+ if (!first) return;
926
+
927
+ hasAutoSelectedPartnerDateRef.current = true;
928
+ handleDateSelectRef.current(first);
929
+ }, [
930
+ flowUi?.autoSelectFirstAvailableDate,
931
+ mode,
932
+ initialValues?.dateTime,
933
+ selectedDate,
934
+ dates,
935
+ availabilitiesByDate,
936
+ isAdmin,
937
+ ]);
938
+
939
+ useEffect(() => {
940
+ if (flowUi?.autoSelectFirstHighlightedPickup !== true) return;
941
+ if (hasAutoSelectedPartnerPickupRef.current) return;
942
+ if (!highlightedPickupLocationIds?.length) return;
943
+ if (pickupLocationId) return;
944
+ if (pickupLocationSkipped) return;
945
+ if (initialValues?.pickupLocationId?.trim()) return;
946
+ const locs = product.pickupLocations;
947
+ if (!locs?.length) return;
948
+ const match = highlightedPickupLocationIds.find((id) => locs.some((l) => l.id === id));
949
+ if (!match) return;
950
+ hasAutoSelectedPartnerPickupRef.current = true;
951
+ setPickupLocationId(match);
952
+ setPickupLocationSkipped(false);
953
+ }, [
954
+ flowUi?.autoSelectFirstHighlightedPickup,
955
+ highlightedPickupLocationIds,
956
+ pickupLocationId,
957
+ pickupLocationSkipped,
958
+ initialValues?.pickupLocationId,
959
+ product.pickupLocations,
960
+ ]);
961
+
962
+ useEffect(() => {
963
+ if (selectedDate && !selectedOption && optionsAvailableForSelectedDate.length > 0) {
964
+ const mostPopular = optionsAvailableForSelectedDate.find((o) => o.mostPopular);
965
+ const opt = mostPopular ?? optionsAvailableForSelectedDate[0];
966
+ setSelectedOption(opt.optionId);
967
+ const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
968
+ const avail = dateAvailabilities.find(
969
+ (a) => (a.productId || a.productOptionId) === opt.optionId
970
+ );
971
+ setSelectedAvailability(avail || null);
972
+ }
973
+ }, [selectedDate, selectedOption, optionsAvailableForSelectedDate, availabilitiesByDate]);
974
+
975
+ // When availability changes, clip passenger count to new vacancies (e.g. user switched date/option).
976
+ // Don't run when passengerCount changes—allows special request for more than vacancies.
977
+ useEffect(() => {
978
+ if (!selectedAvailability) return;
979
+ const vacancies = selectedAvailability.vacancies || 0;
980
+ const minP = 1;
981
+ setIsSpecialRequestMode(false);
982
+ setSpecialRequestInputValue('');
983
+ setPassengerCount((prev) => {
984
+ if (prev <= vacancies) return Math.max(minP, prev);
985
+ return Math.max(minP, vacancies);
986
+ });
987
+ }, [selectedAvailability]);
988
+
989
+ useEffect(() => {
990
+ if (selectedOption) {
991
+ const optionConfig = activeOptions.find((o) => o.optionId === selectedOption);
992
+ const blacklist =
993
+ optionConfig?.privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist ?? [];
994
+ const defaults =
995
+ optionConfig?.privateShuttleConfig?.itineraryBuilderConfig?.defaultDestinations ?? [];
996
+ setDraftItineraryDestinations(defaults.filter((id) => !blacklist.includes(id)));
997
+ setDraftItineraryPlanningNotes('');
998
+ }
999
+ }, [selectedOption, activeOptions]);
1000
+
1001
+ useEffect(() => {
1002
+ if (
1003
+ suggestedStartTimes.length > 0 &&
1004
+ !isCustomTimeMode &&
1005
+ !selectedStartTime
1006
+ ) {
1007
+ setSelectedStartTime(suggestedStartTimes[0]);
1008
+ }
1009
+ }, [suggestedStartTimes, isCustomTimeMode, selectedStartTime]);
1010
+
1011
+ useEffect(() => {
1012
+ if (
1013
+ pricingConfig?.cancellationPolicies &&
1014
+ pricingConfig.cancellationPolicies.length > 0 &&
1015
+ !cancellationPolicyId
1016
+ ) {
1017
+ setCancellationPolicyId(pricingConfig.cancellationPolicies[0].id);
1018
+ }
1019
+ }, [pricingConfig?.cancellationPolicies, cancellationPolicyId]);
1020
+
1021
+ const handleOptionSelect = (optionId: string) => {
1022
+ setSelectedOption(optionId);
1023
+ setError('');
1024
+ if (selectedDate) {
1025
+ const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
1026
+ const avail = dateAvailabilities.find(
1027
+ (a) => (a.productId || a.productOptionId) === optionId
1028
+ );
1029
+ setSelectedAvailability(avail || null);
1030
+ } else {
1031
+ setSelectedAvailability(null);
1032
+ }
1033
+ setSelectedStartTime('');
1034
+ setIsCustomTimeMode(false);
1035
+ };
1036
+
1037
+ const handleStartTimeSelect = (time: string) => {
1038
+ setSelectedStartTime(time);
1039
+ setIsCustomTimeMode(false);
1040
+ setError('');
1041
+ };
1042
+
1043
+ const handleCustomTimeRequest = () => {
1044
+ setIsCustomTimeMode(true);
1045
+ setSelectedStartTime('');
1046
+ setError('');
1047
+ };
1048
+
1049
+ const handleCustomTimeChange = (value: string) => {
1050
+ setSelectedStartTime(value);
1051
+ setError('');
1052
+ };
1053
+
1054
+ const handlePassengerCountChange = (count: number) => {
1055
+ const max = selectedAvailability?.vacancies || 0;
1056
+ const min = 1;
1057
+ setPassengerCount(Math.max(min, max > 0 ? Math.min(max, count) : count));
1058
+ setError('');
1059
+ };
1060
+
1061
+ const handleApplyPromo = useCallback(async () => {
1062
+ const code = promoCodeInput.trim().toUpperCase();
1063
+ if (!code || appliedPromoCode === code) return;
1064
+ if (!product.companyId) return;
1065
+ lastValidatedInputRef.current = code;
1066
+ setPromoCodeError('');
1067
+ setPromoCodeValidating(true);
1068
+ try {
1069
+ const result = await validatePromoCode(
1070
+ code,
1071
+ product.companyId,
1072
+ product.productId,
1073
+ hasOngoingDiscount
1074
+ );
1075
+ if (result.valid) {
1076
+ setAppliedPromoCode(code);
1077
+ fetchedRangesRef.current = [];
1078
+ } else {
1079
+ setPromoCodeError(
1080
+ result.error === 'Promo codes cannot be stacked with deals'
1081
+ ? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
1082
+ : (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code')
1083
+ );
1084
+ }
1085
+ } catch (err) {
1086
+ setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
1087
+ } finally {
1088
+ setPromoCodeValidating(false);
1089
+ }
1090
+ }, [
1091
+ promoCodeInput,
1092
+ appliedPromoCode,
1093
+ product.companyId,
1094
+ product.productId,
1095
+ hasOngoingDiscount,
1096
+ t,
1097
+ ]);
1098
+
1099
+ const handleApplyPromoRef = useRef(handleApplyPromo);
1100
+ handleApplyPromoRef.current = handleApplyPromo;
1101
+
1102
+ const itineraryDisplayItems = useMemo(() => {
1103
+ if (!selectedOption || !selectedStartTime) return [];
1104
+ const items: Array<{
1105
+ time: string | null;
1106
+ label: string;
1107
+ clickableLabel?: string;
1108
+ prefix?: string;
1109
+ timesNote?: string;
1110
+ isProposedStops?: boolean;
1111
+ }> = [];
1112
+ const destMap = product.itineraryBuilder?.destinations
1113
+ ? new Map(product.itineraryBuilder.destinations.map((d) => [d.id, d.label]))
1114
+ : new Map<string, string>();
1115
+ if (draftItineraryDestinations.length > 0) {
1116
+ const labels = draftItineraryDestinations
1117
+ .map((id) => destMap.get(id) || id.replace(/_/g, ' '))
1118
+ .join(', ');
1119
+ items.push({
1120
+ time: null,
1121
+ label: labels,
1122
+ isProposedStops: true,
1123
+ timesNote: t('booking.orderAndTimesToBeConfirmed') || '(order and times to be confirmed)',
1124
+ });
1125
+ }
1126
+ const pickupLabel = pickupLocationSkipped
1127
+ ? (t('booking.pickupLocationUnknown') || "I don't know")
1128
+ : customPickupAddress
1129
+ ? customPickupAddress
1130
+ : selectedPickupLocation?.name;
1131
+ items.push({
1132
+ time: selectedStartTime,
1133
+ label: pickupLabel || (t('booking.pickupAt') || 'Pickup'),
1134
+ clickableLabel: !pickupLocationId && !customPickupAddress && !pickupLocationSkipped ? (t('pickup.title') || 'Select pickup') : undefined,
1135
+ prefix: t('booking.pickupAtPrefix') || 'Pickup at ',
1136
+ });
1137
+ return items;
1138
+ }, [
1139
+ selectedOption,
1140
+ selectedStartTime,
1141
+ draftItineraryDestinations,
1142
+ customPickupAddress,
1143
+ selectedPickupLocation,
1144
+ pickupLocationSkipped,
1145
+ pickupLocationId,
1146
+ product.itineraryBuilder,
1147
+ t,
1148
+ ]);
1149
+
1150
+ const cancelPendingReservation = useCallback(() => {
1151
+ if (paymentSubmitInFlightRef.current) return;
1152
+ const pending = pendingReservationRef.current;
1153
+ if (!pending) return;
1154
+ pendingReservationRef.current = null;
1155
+ cancelReservation(pending.reservationReference).catch(() => {});
1156
+ setShowCheckoutModal(false);
1157
+ setCheckoutModalData(null);
1158
+ setCheckoutClientSecret('');
1159
+ }, []);
1160
+
1161
+ const cancelPendingReservationBestEffort = useCallback(() => {
1162
+ if (paymentSubmitInFlightRef.current) return;
1163
+ const pending = pendingReservationRef.current;
1164
+ if (!pending) return;
1165
+ pendingReservationRef.current = null;
1166
+ cancelReservationBestEffort(pending.reservationReference);
1167
+ setShowCheckoutModal(false);
1168
+ setCheckoutModalData(null);
1169
+ setCheckoutClientSecret('');
1170
+ }, []);
1171
+
1172
+ // Parent surfaces (dialog close / embedded back) emit this when user abandons the booking flow.
1173
+ useEffect(() => {
1174
+ const handleAbandon = () => {
1175
+ cancelPendingReservation();
1176
+ };
1177
+ window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
1178
+ return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
1179
+ }, [cancelPendingReservation]);
1180
+
1181
+ useEffect(() => {
1182
+ const handlePageHide = () => {
1183
+ cancelPendingReservationBestEffort();
1184
+ };
1185
+ window.addEventListener('pagehide', handlePageHide);
1186
+ return () => window.removeEventListener('pagehide', handlePageHide);
1187
+ }, [cancelPendingReservationBestEffort]);
1188
+
1189
+ const handleCheckout = async () => {
1190
+ if (!selectedDate || !selectedStartTime || passengerCount < 1) {
1191
+ setError('Please select a date, start time, and passenger count');
1192
+ return;
1193
+ }
1194
+ if (
1195
+ product.pickupLocations &&
1196
+ product.pickupLocations.length > 0 &&
1197
+ !pickupLocationId &&
1198
+ !customPickupAddress &&
1199
+ !pickupLocationSkipped
1200
+ ) {
1201
+ setError(t('booking.selectPickupLocation'));
1202
+ return;
1203
+ }
1204
+ if (!email) {
1205
+ setError(t('booking.enterEmail') || 'Please enter your email address');
1206
+ return;
1207
+ }
1208
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1209
+ setError(t('booking.invalidEmail') || 'Please enter a valid email address');
1210
+ return;
1211
+ }
1212
+ if (!firstName?.trim()) {
1213
+ setError(t('booking.enterFirstName') || 'Please enter your first name');
1214
+ return;
1215
+ }
1216
+ if (!lastName?.trim()) {
1217
+ setError(t('booking.enterLastName') || 'Please enter your last name');
1218
+ return;
1219
+ }
1220
+
1221
+ setLoading(true);
1222
+ setError('');
1223
+ paymentSubmitInFlightRef.current = false;
1224
+
1225
+ try {
1226
+ if (!selectedOption) {
1227
+ setError('No product option selected');
1228
+ setLoading(false);
1229
+ return;
1230
+ }
1231
+ const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
1232
+ clientChannelSource: inferClientBookingSourceFromProductIds(product.productId, selectedOption),
1233
+ forcePartnerPortalChannel: partnerPortalBooking,
1234
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
1235
+ });
1236
+ const bookingItems = [{ category: 'RESOURCE' as const, count: billableResourceCount }];
1237
+ const [hours, minutes] = selectedStartTime.split(':').map(Number);
1238
+ const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
1239
+
1240
+ const specialRequestNote = isSpecialRequest
1241
+ ? `Special request: Customer requested ${passengerCount} passengers (${resourceCount} shuttles needed). We reserved ${billableResourceCount} at standard capacity. Team to add ${resourceCount - billableResourceCount} more shuttle(s) and verify fleet availability.`
1242
+ : '';
1243
+ const userPlanningNotes = draftItineraryPlanningNotes.trim();
1244
+ const planningNotes = [specialRequestNote, userPlanningNotes].filter(Boolean).join('\n\n');
1245
+
1246
+ let reservation: { reservationReference: string; expiresAt?: string; totalAmount?: number; currency?: string };
1247
+ reservation = await createReservation({
1248
+ productId: selectedOption,
1249
+ dateTime: selectedDate,
1250
+ availabilityId: selectedAvailability?.availabilityId || undefined,
1251
+ bookingItems,
1252
+ currency,
1253
+ startTime: startTimeISO,
1254
+ passengerCount: isSpecialRequest ? Math.min(passengerCount, maxVacancies) : passengerCount,
1255
+ requestedPassengerCount: isSpecialRequest ? passengerCount : undefined,
1256
+ pickupLocationId: pickupLocationId || undefined,
1257
+ cancellationPolicyId: cancellationPolicyId || undefined,
1258
+ promoCode: (isAdmin ? appliedPromoCode : null) || undefined,
1259
+ travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1260
+ draftItinerary:
1261
+ draftItineraryDestinations.length > 0 || planningNotes
1262
+ ? {
1263
+ destinations: draftItineraryDestinations,
1264
+ planningNotes: planningNotes || undefined,
1265
+ }
1266
+ : undefined,
1267
+ childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : undefined,
1268
+ foodRestrictions: foodRestrictions.trim() || undefined,
1269
+ addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
1270
+ additionalHoursCount: isAdmin && additionalHoursCount > 0 ? additionalHoursCount : undefined,
1271
+ ...(isAdmin ? { allowOverbook: true } : {}),
1272
+ ...(bookingSourceContext.sourceMetadata
1273
+ ? {
1274
+ source: bookingSourceContext.source,
1275
+ sourceMetadata: bookingSourceContext.sourceMetadata,
1276
+ source_metadata: bookingSourceContext.source_metadata,
1277
+ }
1278
+ : {}),
1279
+ });
1280
+ pendingReservationRef.current = { reservationReference: reservation.reservationReference };
1281
+
1282
+ if (!reservation?.reservationReference) {
1283
+ throw new Error('Invalid reservation response: missing reservationReference');
1284
+ }
1285
+
1286
+ const itineraryDisplayForStorage: ItineraryDisplayStep[] = itineraryDisplayItems.map(
1287
+ (item, i) => {
1288
+ if (item.isProposedStops) {
1289
+ return {
1290
+ stepType: ItineraryStepType.draft,
1291
+ time: 'Proposed stops',
1292
+ place: item.label,
1293
+ };
1294
+ }
1295
+ const isPickup = i === 0;
1296
+ return {
1297
+ stepType: isPickup ? ItineraryStepType.pickup : ItineraryStepType.drop_off,
1298
+ time: item.time ?? 'TBD',
1299
+ place: item.clickableLabel ?? item.label,
1300
+ };
1301
+ }
1302
+ );
1303
+
1304
+ const lines = [
1305
+ {
1306
+ label: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
1307
+ amount: basePrice,
1308
+ type: 'TICKET' as const,
1309
+ quantity: resourceCount,
1310
+ },
1311
+ ...addOnSelections
1312
+ .map((sel) => {
1313
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1314
+ if (!addOn) return null;
1315
+ const base = addOn.price ?? 0;
1316
+ const hasVariant =
1317
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
1318
+ sel.variantId;
1319
+ const adj = hasVariant
1320
+ ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1321
+ : 0;
1322
+ const amt = (base + adj) * (sel.quantity ?? 1);
1323
+ const variantLabel = hasVariant
1324
+ ? addOn.variants?.find((v) => v.id === sel.variantId)?.label
1325
+ : null;
1326
+ const qty = sel.quantity ?? 1;
1327
+ return {
1328
+ label: variantLabel
1329
+ ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}`
1330
+ : addOn.name,
1331
+ amount: amt,
1332
+ type: 'FEE' as const,
1333
+ };
1334
+ })
1335
+ .filter(Boolean) as Array<{ label: string; amount: number; type: 'FEE' }>,
1336
+ ...(additionalHoursAmount > 0
1337
+ ? [
1338
+ {
1339
+ label:
1340
+ additionalHoursCount === 1
1341
+ ? 'Additional hour'
1342
+ : `Additional hours (${additionalHoursCount})`,
1343
+ amount: additionalHoursAmount,
1344
+ type: 'ADDITIONAL_HOURS' as const,
1345
+ },
1346
+ ]
1347
+ : []),
1348
+ ...(cancellationPolicyFee > 0 && selectedCancellationPolicy
1349
+ ? [
1350
+ {
1351
+ label: selectedCancellationPolicy.label,
1352
+ amount: cancellationPolicyFee,
1353
+ type: 'CANCELLATION_UPGRADE' as const,
1354
+ },
1355
+ ]
1356
+ : []),
1357
+ ...(taxAmount > 0
1358
+ ? [
1359
+ {
1360
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
1361
+ amount: taxAmount,
1362
+ type: 'TAX' as const,
1363
+ },
1364
+ ]
1365
+ : []),
1366
+ ...(effectiveTaxAmount > 0 ? [{ label: 'GST', amount: effectiveTaxAmount, type: 'TAX' as const }] : []),
1367
+ ];
1368
+ const receiptTotal = depositInfo ? depositInfo.totalPrice : totalPrice;
1369
+ const linesWithPromo = [
1370
+ ...lines,
1371
+ ...(effectivePromoDiscountAmount > 0
1372
+ ? [
1373
+ {
1374
+ label:
1375
+ appliedPromoCode
1376
+ ? `Promo: ${appliedPromoCode}`
1377
+ : (t('booking.discount') || 'Discount'),
1378
+ amount: -effectivePromoDiscountAmount,
1379
+ type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
1380
+ },
1381
+ ]
1382
+ : []),
1383
+ ];
1384
+ const checkoutBreakdown = buildCheckoutBreakdown({
1385
+ lines: linesWithPromo,
1386
+ totalAmount: receiptTotal,
1387
+ currency,
1388
+ roundingLabel: t('booking.rounding') || 'Rounding',
1389
+ });
1390
+
1391
+ if (flowUi?.partnerDeferredInvoice) {
1392
+ const depositAmount = depositInfo ? depositInfo.depositAmount : 0;
1393
+ const balanceAmount = depositInfo ? depositInfo.balanceAmount : receiptTotal;
1394
+ const confirmedBooking = await confirmPartnerBookingWithoutPayment({
1395
+ reservationReference: reservation.reservationReference,
1396
+ productId: product.productId,
1397
+ optionId: selectedOption,
1398
+ date: selectedDate,
1399
+ time: selectedStartTime,
1400
+ customerEmail: email || undefined,
1401
+ customerFirstName: firstName.trim() || undefined,
1402
+ customerLastName: lastName.trim() || undefined,
1403
+ currency,
1404
+ travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1405
+ pickupLocationId: pickupLocationId || undefined,
1406
+ itineraryDisplay: itineraryDisplayForStorage,
1407
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
1408
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1409
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
1410
+ checkoutBreakdown,
1411
+ depositAmount,
1412
+ balanceAmount,
1413
+ totalAmount: receiptTotal,
1414
+ balanceChargeDaysBefore: depositInfo ? (privateShuttleConfig?.balanceChargeDaysBefore ?? 7) : undefined,
1415
+ ...bookingSourceContext,
1416
+ });
1417
+ pendingReservationRef.current = null;
1418
+ const ref = formatBookingRefForDisplay(confirmedBooking.bookingReference);
1419
+ const ln = lastName.trim();
1420
+ onSuccess?.({
1421
+ reservationReference: reservation.reservationReference,
1422
+ bookingReference: confirmedBooking.bookingReference,
1423
+ });
1424
+ if (onShowManage) {
1425
+ onShowManage({ ref, lastName: ln });
1426
+ } else {
1427
+ window.location.href = `/manage-booking?ref=${ref}&lastName=${encodeURIComponent(ln)}&booking_complete=1`;
1428
+ }
1429
+ setLoading(false);
1430
+ return;
1431
+ }
1432
+
1433
+ if (isAdmin && depositInfo) {
1434
+ const paymentIntent = await createPaymentIntent({
1435
+ productId: product.productId,
1436
+ optionId: selectedOption,
1437
+ date: selectedDate,
1438
+ time: selectedStartTime,
1439
+ quantity: resourceCount,
1440
+ customerEmail: email,
1441
+ customerFirstName: firstName.trim() || undefined,
1442
+ customerLastName: lastName.trim() || undefined,
1443
+ currency,
1444
+ reservationReference: reservation.reservationReference,
1445
+ travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1446
+ pickupLocationId: pickupLocationId || undefined,
1447
+ itineraryDisplay: itineraryDisplayForStorage,
1448
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
1449
+ cancellationPolicyId: cancellationPolicyId || undefined,
1450
+ promoCode: (isAdmin ? appliedPromoCode : null) || undefined,
1451
+ checkoutBreakdown,
1452
+ paymentPlanType: 'DEPOSIT',
1453
+ depositAmount: depositInfo.depositAmount,
1454
+ balanceAmount: depositInfo.balanceAmount,
1455
+ totalAmount: depositInfo.totalPrice,
1456
+ balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
1457
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1458
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
1459
+ ...bookingSourceContext,
1460
+ });
1461
+ setAdminChoiceData({
1462
+ reservationReference: reservation.reservationReference,
1463
+ reservationExpiration: reservation.expiresAt,
1464
+ checkoutBreakdown,
1465
+ totalAmount: depositInfo.totalPrice,
1466
+ datePart: selectedDate,
1467
+ timePart: selectedStartTime,
1468
+ availabilityProductOptionId: selectedOption,
1469
+ itineraryDisplay: itineraryDisplayForStorage,
1470
+ clientSecret: paymentIntent.clientSecret ?? '',
1471
+ ticketLinesForModal: [
1472
+ {
1473
+ line: {
1474
+ category: 'Shuttle',
1475
+ qty: resourceCount,
1476
+ pricePerUnit: resourcePrice,
1477
+ itemTotal: basePrice,
1478
+ },
1479
+ breakdown: null,
1480
+ },
1481
+ ],
1482
+ feeLineItems: addOnSelections
1483
+ .map((sel) => {
1484
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1485
+ if (!addOn) return null;
1486
+ const base = addOn.price ?? 0;
1487
+ const hasVariant =
1488
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
1489
+ sel.variantId;
1490
+ const adj = hasVariant
1491
+ ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1492
+ : 0;
1493
+ return {
1494
+ name: addOn.name,
1495
+ totalAmount: (base + adj) * (sel.quantity ?? 1),
1496
+ };
1497
+ })
1498
+ .filter(Boolean) as { name: string; totalAmount: number }[],
1499
+ cancellationPolicyFee,
1500
+ cancellationPolicyLabel: selectedCancellationPolicy?.label,
1501
+ subtotal,
1502
+ tax: effectiveTaxAmount,
1503
+ totalQuantity: resourceCount,
1504
+ isTaxIncludedInPrice,
1505
+ taxRate,
1506
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
1507
+ discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
1508
+ });
1509
+ setShowAdminPaymentChoice(true);
1510
+ setLoading(false);
1511
+ return;
1512
+ }
1513
+
1514
+ const paymentIntent = await createPaymentIntent({
1515
+ productId: product.productId,
1516
+ optionId: selectedOption,
1517
+ date: selectedDate,
1518
+ time: selectedStartTime,
1519
+ quantity: resourceCount,
1520
+ customerEmail: email,
1521
+ customerFirstName: firstName.trim() || undefined,
1522
+ customerLastName: lastName.trim() || undefined,
1523
+ currency,
1524
+ reservationReference: reservation.reservationReference,
1525
+ travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1526
+ pickupLocationId: pickupLocationId || undefined,
1527
+ itineraryDisplay: itineraryDisplayForStorage,
1528
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
1529
+ cancellationPolicyId: cancellationPolicyId || undefined,
1530
+ promoCode: (isAdmin ? appliedPromoCode : null) || undefined,
1531
+ checkoutBreakdown,
1532
+ ...(depositInfo && {
1533
+ paymentPlanType: 'DEPOSIT' as const,
1534
+ depositAmount: depositInfo.depositAmount,
1535
+ balanceAmount: depositInfo.balanceAmount,
1536
+ totalAmount: depositInfo.totalPrice,
1537
+ balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
1538
+ }),
1539
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1540
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
1541
+ ...bookingSourceContext,
1542
+ });
1543
+
1544
+ if (paymentIntent.freeBooking) {
1545
+ const freeResult = await confirmFreeBooking({
1546
+ reservationReference: reservation.reservationReference,
1547
+ productId: product.productId,
1548
+ optionId: selectedOption,
1549
+ date: selectedDate,
1550
+ time: selectedStartTime,
1551
+ customerEmail: email || undefined,
1552
+ customerFirstName: firstName.trim() || undefined,
1553
+ customerLastName: lastName.trim() || undefined,
1554
+ currency,
1555
+ travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1556
+ pickupLocationId: pickupLocationId || undefined,
1557
+ itineraryDisplay: itineraryDisplayForStorage,
1558
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
1559
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1560
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
1561
+ ...bookingSourceContext,
1562
+ });
1563
+ pendingReservationRef.current = null;
1564
+ const ref = formatBookingRefForDisplay(freeResult.bookingReference);
1565
+ onSuccess?.({ reservationReference: reservation.reservationReference });
1566
+ if (onShowManage) {
1567
+ onShowManage({ ref, lastName: lastName.trim() });
1568
+ } else {
1569
+ window.location.href = `/manage-booking?ref=${ref}&lastName=${encodeURIComponent(lastName.trim())}&booking_complete=1`;
1570
+ }
1571
+ setLoading(false);
1572
+ return;
1573
+ }
1574
+
1575
+ const ticketLinesForModal: CheckoutModalLineItem[] = [
1576
+ {
1577
+ line: {
1578
+ category: 'Shuttle',
1579
+ qty: resourceCount,
1580
+ pricePerUnit: resourcePrice,
1581
+ itemTotal: basePrice,
1582
+ },
1583
+ breakdown: null,
1584
+ },
1585
+ ];
1586
+ const feeLineItemsForModal = addOnSelections
1587
+ .map((sel) => {
1588
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1589
+ if (!addOn) return null;
1590
+ const base = addOn.price ?? 0;
1591
+ const hasVariant =
1592
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
1593
+ sel.variantId;
1594
+ const adj = hasVariant
1595
+ ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1596
+ : 0;
1597
+ return {
1598
+ name: addOn.name,
1599
+ totalAmount: (base + adj) * (sel.quantity ?? 1),
1600
+ };
1601
+ })
1602
+ .filter(Boolean) as { name: string; totalAmount: number }[];
1603
+
1604
+ setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
1605
+ setCheckoutModalData({
1606
+ reservationReference: reservation.reservationReference,
1607
+ reservationExpiration: reservation.expiresAt,
1608
+ customerLastName: lastName.trim(),
1609
+ bookingDate: selectedDate,
1610
+ ticketLines: ticketLinesForModal,
1611
+ feeLineItems: feeLineItemsForModal,
1612
+ returnPriceAdjustment: 0,
1613
+ subtotal: depositInfo ? depositInfo.totalPrice : totalPrice,
1614
+ tax: effectiveTaxAmount,
1615
+ total: depositInfo ? depositInfo.depositAmount : totalPrice,
1616
+ totalQuantity: resourceCount,
1617
+ isTaxIncludedInPrice,
1618
+ taxRate,
1619
+ cancellationPolicyFee,
1620
+ cancellationPolicyLabel: selectedCancellationPolicy?.label,
1621
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
1622
+ discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
1623
+ isDepositPayment: !!depositInfo,
1624
+ balanceChargeDaysBefore: depositInfo ? (privateShuttleConfig?.balanceChargeDaysBefore ?? 7) : undefined,
1625
+ });
1626
+ setShowCheckoutModal(true);
1627
+ setLoading(false);
1628
+ } catch (err) {
1629
+ if (isInsufficientCapacityReserveError(err)) {
1630
+ try {
1631
+ const merged = await reloadAvailabilitiesAfterReserveConflict();
1632
+ const slot = findMergedPrivateShuttleAvailability(
1633
+ merged,
1634
+ selectedAvailability,
1635
+ selectedOption || null,
1636
+ selectedDate || null,
1637
+ companyTimezone
1638
+ );
1639
+ setError(
1640
+ describePrivateShuttleCapacityConflictMessage({
1641
+ passengersRequested: passengerCount,
1642
+ vacancies: slot?.vacancies ?? null,
1643
+ })
1644
+ );
1645
+ reportReserveCapacityConflictClientContext({
1646
+ flow: 'private_shuttle',
1647
+ productId: product.productId,
1648
+ selectedDate: selectedDate || null,
1649
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
1650
+ outboundVacanciesAfterRefresh: slot?.vacancies ?? null,
1651
+ returnVacanciesAfterRefresh: null,
1652
+ partySizeOrPassengers: passengerCount,
1653
+ hasReturnSelection: false,
1654
+ returnAvailabilityId: null,
1655
+ reloadAvailabilitiesSucceeded: true,
1656
+ });
1657
+ } catch (reloadErr) {
1658
+ setError(
1659
+ describePrivateShuttleCapacityConflictMessage({
1660
+ passengersRequested: passengerCount,
1661
+ vacancies: null,
1662
+ })
1663
+ );
1664
+ reportReserveCapacityConflictClientContext({
1665
+ flow: 'private_shuttle',
1666
+ productId: product.productId,
1667
+ selectedDate: selectedDate || null,
1668
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
1669
+ outboundVacanciesAfterRefresh: null,
1670
+ returnVacanciesAfterRefresh: null,
1671
+ partySizeOrPassengers: passengerCount,
1672
+ hasReturnSelection: false,
1673
+ returnAvailabilityId: null,
1674
+ reloadAvailabilitiesSucceeded: false,
1675
+ reloadErrorMessage:
1676
+ reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
1677
+ });
1678
+ }
1679
+ setLoading(false);
1680
+ return;
1681
+ }
1682
+ setError(err instanceof Error ? err.message : 'Something went wrong');
1683
+ setLoading(false);
1684
+ }
1685
+ };
1686
+
1687
+ const handleConfirmWithoutPayment = async () => {
1688
+ if (!adminChoiceData) return;
1689
+ setLoading(true);
1690
+ setError('');
1691
+ try {
1692
+ const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
1693
+ clientChannelSource: inferClientBookingSourceFromProductIds(
1694
+ product.productId,
1695
+ adminChoiceData.availabilityProductOptionId,
1696
+ ),
1697
+ forcePartnerPortalChannel: partnerPortalBooking,
1698
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
1699
+ });
1700
+ await confirmBookingWithoutPayment({
1701
+ reservationReference: adminChoiceData.reservationReference,
1702
+ productId: product.productId,
1703
+ optionId: adminChoiceData.availabilityProductOptionId,
1704
+ date: adminChoiceData.datePart,
1705
+ time: adminChoiceData.timePart,
1706
+ customerEmail: email || undefined,
1707
+ customerFirstName: firstName.trim() || undefined,
1708
+ customerLastName: lastName.trim() || undefined,
1709
+ currency,
1710
+ travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1711
+ pickupLocationId: pickupLocationId || undefined,
1712
+ itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
1713
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
1714
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1715
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
1716
+ checkoutBreakdown: adminChoiceData.checkoutBreakdown,
1717
+ depositAmount: 0,
1718
+ balanceAmount: adminChoiceData.totalAmount,
1719
+ totalAmount: adminChoiceData.totalAmount,
1720
+ ...bookingSourceContext,
1721
+ });
1722
+ pendingReservationRef.current = null;
1723
+ const ref = formatBookingRefForDisplay(adminChoiceData.reservationReference);
1724
+ setShowAdminPaymentChoice(false);
1725
+ setAdminChoiceData(null);
1726
+ onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
1727
+ if (onShowManage) {
1728
+ onShowManage({ ref, lastName: lastName.trim() });
1729
+ } else {
1730
+ window.location.href = `/manage-booking?ref=${ref}&lastName=${encodeURIComponent(lastName.trim())}&booking_complete=1`;
1731
+ }
1732
+ } catch (err) {
1733
+ setError(err instanceof Error ? err.message : 'Failed to confirm booking');
1734
+ } finally {
1735
+ setLoading(false);
1736
+ }
1737
+ };
1738
+
1739
+ const handlePayNow = () => {
1740
+ if (!adminChoiceData) return;
1741
+ setShowAdminPaymentChoice(false);
1742
+ setCheckoutClientSecret(adminChoiceData.clientSecret);
1743
+ setCheckoutModalData({
1744
+ reservationReference: adminChoiceData.reservationReference,
1745
+ reservationExpiration: adminChoiceData.reservationExpiration,
1746
+ customerLastName: lastName.trim(),
1747
+ bookingDate: adminChoiceData.datePart,
1748
+ ticketLines: adminChoiceData.ticketLinesForModal,
1749
+ feeLineItems: adminChoiceData.feeLineItems,
1750
+ returnPriceAdjustment: 0,
1751
+ subtotal: adminChoiceData.subtotal,
1752
+ tax: adminChoiceData.tax,
1753
+ total: adminChoiceData.totalAmount,
1754
+ totalQuantity: adminChoiceData.totalQuantity,
1755
+ isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
1756
+ taxRate: adminChoiceData.taxRate,
1757
+ cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
1758
+ cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
1759
+ promoDiscountAmount: adminChoiceData.promoDiscountAmount,
1760
+ discountLabel: adminChoiceData.discountLabel,
1761
+ });
1762
+ setShowCheckoutModal(true);
1763
+ setAdminChoiceData(null);
1764
+ };
1765
+
1766
+ if (activeOptions.length === 0) {
1767
+ return (
1768
+ <div className="flex items-center justify-center py-16">
1769
+ <div className="text-red-600">
1770
+ {t('booking.noActiveOption') || 'No active product options available'}
1771
+ </div>
1772
+ </div>
1773
+ );
1774
+ }
1775
+
1776
+ const config = productId ? getProductByIdOrSlug(productId) : null;
1777
+ const displayProducts = getProducts(defaultStrings);
1778
+ const displayProduct = Object.values(displayProducts).find((p) => p.id === productId);
1779
+ const collageImageIds = config?.display?.collageImageIds ?? config?.display?.imageIds ?? [];
1780
+ const hasVideo = !!displayProduct?.videoUrl;
1781
+ const hasImages = collageImageIds.length > 0;
1782
+
1783
+ return (
1784
+ <div className={`booking-flow-root private-shuttle-booking-flow ${styles.root} space-y-8`}>
1785
+ <AdminPaymentChoiceModal
1786
+ open={!!(showAdminPaymentChoice && adminChoiceData)}
1787
+ totalAmount={adminChoiceData?.totalAmount ?? 0}
1788
+ currency={currency}
1789
+ loading={loading}
1790
+ error={error}
1791
+ onPayNow={handlePayNow}
1792
+ onConfirmWithoutPayment={handleConfirmWithoutPayment}
1793
+ onCancel={() => {
1794
+ setShowAdminPaymentChoice(false);
1795
+ setAdminChoiceData(null);
1796
+ setError('');
1797
+ }}
1798
+ />
1799
+ {checkoutModalData && (
1800
+ <CheckoutModal
1801
+ open={showCheckoutModal}
1802
+ onClose={cancelPendingReservation}
1803
+ onPaymentSubmitStart={() => {
1804
+ paymentSubmitInFlightRef.current = true;
1805
+ }}
1806
+ onPaymentSubmitError={() => {
1807
+ paymentSubmitInFlightRef.current = false;
1808
+ }}
1809
+ clientSecret={checkoutClientSecret}
1810
+ reservationReference={checkoutModalData.reservationReference}
1811
+ reservationExpiration={checkoutModalData.reservationExpiration}
1812
+ customerLastName={checkoutModalData.customerLastName}
1813
+ successUrlOverride={
1814
+ getSuccessUrl
1815
+ ? getSuccessUrl({
1816
+ reservationRef: checkoutModalData.reservationReference,
1817
+ lastName: checkoutModalData.customerLastName ?? '',
1818
+ focusDate: checkoutModalData.bookingDate,
1819
+ })
1820
+ : undefined
1821
+ }
1822
+ ticketLines={checkoutModalData.ticketLines}
1823
+ feeLineItems={checkoutModalData.feeLineItems}
1824
+ returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
1825
+ cancellationPolicyFee={checkoutModalData.cancellationPolicyFee ?? 0}
1826
+ cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
1827
+ subtotal={checkoutModalData.subtotal}
1828
+ tax={checkoutModalData.tax}
1829
+ total={checkoutModalData.total}
1830
+ promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
1831
+ discountLabel={checkoutModalData.discountLabel}
1832
+ totalQuantity={checkoutModalData.totalQuantity}
1833
+ isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
1834
+ taxRate={checkoutModalData.taxRate}
1835
+ currency={currency}
1836
+ locale={locale}
1837
+ t={t}
1838
+ isDepositPayment={checkoutModalData.isDepositPayment}
1839
+ balanceChargeDaysBefore={checkoutModalData.balanceChargeDaysBefore}
1840
+ />
1841
+ )}
1842
+
1843
+ {productId && flowUi?.showCollage !== false && (hasVideo || hasImages) && (
1844
+ <div className="booking-collage-wrapper">
1845
+ <BookingFlowCollage
1846
+ video={displayProduct?.videoUrl}
1847
+ videoPosterImageId={config?.display?.imageIds?.[0]}
1848
+ imageIds={
1849
+ hasImages
1850
+ ? collageImageIds
1851
+ : (config?.display?.imageIds?.[0] ? [config.display.imageIds[0]] : [])
1852
+ }
1853
+ altPrefix={product.name}
1854
+ />
1855
+ </div>
1856
+ )}
1857
+
1858
+ {flowUi?.showTourDescription !== false && (
1859
+ <TourDescription productSlug={productId} locale={locale} />
1860
+ )}
1861
+
1862
+ {loadingAvailabilities && availabilities.length === 0 ? (
1863
+ <div className="flex flex-col items-center justify-center py-12 gap-4">
1864
+ <div className="booking-loading-spinner" aria-hidden />
1865
+ <div className="text-stone-600">{t('booking.loadingTimes')}</div>
1866
+ </div>
1867
+ ) : availabilities.length === 0 ? (
1868
+ <div className="text-center py-8 text-stone-500">{t('booking.noAvailability')}</div>
1869
+ ) : (
1870
+ <>
1871
+ {/* Form sections - match BookingFlow mt-6 space-y-6 spacing */}
1872
+ <div className="mt-6 space-y-6">
1873
+ <div className="relative">
1874
+ {loadingAvailabilities && (
1875
+ <div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
1876
+ <div className="text-stone-600">{t('booking.loadingTimes')}</div>
1877
+ </div>
1878
+ )}
1879
+ <Calendar
1880
+ availabilitiesByDate={availabilitiesByDate}
1881
+ selectedDate={selectedDate}
1882
+ isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
1883
+ onDateSelect={handleDateSelect}
1884
+ timezone={companyTimezone}
1885
+ displayMode="status"
1886
+ earliestDate={earliestAvailabilityDate}
1887
+ onVisibleRangeChange={handleVisibleRangeChange}
1888
+ currency={currency}
1889
+ showCapacity={isAdmin}
1890
+ />
1891
+ </div>
1892
+
1893
+ {selectedDate && optionsAvailableForSelectedDate.length > 0 && (
1894
+ <div className={styles.section}>
1895
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
1896
+ {t('booking.selectTourOption')}
1897
+ </label>
1898
+ <div className={styles.optionGrid}>
1899
+ {[...optionsAvailableForSelectedDate]
1900
+ .sort((a, b) => (b.mostPopular ? 1 : 0) - (a.mostPopular ? 1 : 0))
1901
+ .map((option) => {
1902
+ const isSelected = selectedOption === option.optionId;
1903
+ const baseDurationMinutes =
1904
+ option.privateShuttleConfig?.baseDurationMinutes ?? 0;
1905
+ const hours = baseDurationMinutes / 60;
1906
+ const price = getOptionPrice(option.optionId);
1907
+ const formattedPrice = formatCurrencyAmount(
1908
+ price,
1909
+ currency,
1910
+ locale as 'en' | 'fr'
1911
+ );
1912
+ const hoursRounded = Math.round(hours);
1913
+ const isMostPopular =
1914
+ optionsAvailableForSelectedDate.length > 1 && option.mostPopular;
1915
+ return (
1916
+ <button
1917
+ key={option.optionId}
1918
+ type="button"
1919
+ onClick={() => handleOptionSelect(option.optionId)}
1920
+ className={`${styles.btnOption} private-shuttle-btn-option ${
1921
+ isMostPopular ? styles.btnOptionWithBadge : ''
1922
+ } ${
1923
+ isSelected ? styles.btnOptionSelected : styles.btnOptionDefault
1924
+ }`}
1925
+ >
1926
+ <div className="font-semibold">{option.name}</div>
1927
+ {price > 0 && hours >= 1 && (
1928
+ <div
1929
+ className={`mt-1 text-xs ${
1930
+ isSelected ? 'text-emerald-100' : 'text-stone-500'
1931
+ }`}
1932
+ >
1933
+ {(t('booking.startingAtForHours') ?? 'Starting at {price} per shuttle for {hours}.')
1934
+ .replace('{price}', formattedPrice)
1935
+ .replace('{hours}', `${hoursRounded} ${t('booking.hoursUnit') || 'hours'}`)}
1936
+ </div>
1937
+ )}
1938
+ {isMostPopular && (
1939
+ <span className={styles.badge}>
1940
+ {t('booking.mostPopular')}
1941
+ </span>
1942
+ )}
1943
+ </button>
1944
+ );
1945
+ })}
1946
+ </div>
1947
+ </div>
1948
+ )}
1949
+
1950
+ {selectedOption && selectedAvailability && (
1951
+ <div className={styles.section}>
1952
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
1953
+ Number of Passengers
1954
+ </label>
1955
+ <div className={styles.passengerBox}>
1956
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
1957
+ <div>
1958
+ <div className="font-medium text-stone-900">Passengers</div>
1959
+ <div className="text-sm text-stone-600">
1960
+ {resourceCount} {resourceCount === 1 ? 'shuttle' : 'shuttles'} needed
1961
+ </div>
1962
+ </div>
1963
+ {(() => {
1964
+ const maxVacancies = selectedAvailability?.vacancies || 0;
1965
+ const passengerSelectMin = 1;
1966
+ const specialRequestMin = maxVacancies + 1;
1967
+ const showSpecialRequestInput = isSpecialRequestMode;
1968
+ const canLeaveSpecialRequest = true;
1969
+ return (
1970
+ <>
1971
+ {showSpecialRequestInput ? (
1972
+ <div className="flex flex-col sm:flex-row sm:items-center gap-2">
1973
+ <input
1974
+ type="number"
1975
+ min={specialRequestMin}
1976
+ max={100}
1977
+ value={specialRequestInputValue || String(passengerCount)}
1978
+ onChange={(e) => {
1979
+ const raw = e.target.value;
1980
+ setSpecialRequestInputValue(raw);
1981
+ const val = parseInt(raw, 10);
1982
+ if (!Number.isNaN(val) && val >= specialRequestMin && val <= 100) {
1983
+ setPassengerCount(val);
1984
+ }
1985
+ }}
1986
+ onBlur={() => {
1987
+ const val = parseInt(specialRequestInputValue, 10);
1988
+ const clamped = Math.max(
1989
+ specialRequestMin,
1990
+ Math.min(100, Number.isNaN(val) ? specialRequestMin : val)
1991
+ );
1992
+ setPassengerCount(clamped);
1993
+ setSpecialRequestInputValue(String(clamped));
1994
+ }}
1995
+ className={`${styles.input} ${styles.passengerInput}`}
1996
+ style={{ minWidth: '4.5rem', textAlign: 'center' }}
1997
+ />
1998
+ {canLeaveSpecialRequest && (
1999
+ <button
2000
+ type="button"
2001
+ onClick={() => {
2002
+ setIsSpecialRequestMode(false);
2003
+ setSpecialRequestInputValue('');
2004
+ setPassengerCount(Math.max(1, maxVacancies));
2005
+ }}
2006
+ className="text-sm text-stone-600 underline hover:text-stone-800"
2007
+ >
2008
+ Back to standard ({maxVacancies} max)
2009
+ </button>
2010
+ )}
2011
+ </div>
2012
+ ) : passengerSelectMin <= maxVacancies ? (
2013
+ <select
2014
+ value={passengerCount}
2015
+ onChange={(e) =>
2016
+ handlePassengerCountChange(Number(e.target.value))
2017
+ }
2018
+ className={`${styles.input} ${styles.passengerSelect}`}
2019
+ style={{ minWidth: '4.5rem', textAlign: 'center' }}
2020
+ >
2021
+ {Array.from(
2022
+ {
2023
+ length: Math.max(1, maxVacancies - passengerSelectMin + 1),
2024
+ },
2025
+ (_, i) => i + passengerSelectMin
2026
+ ).map((n) => (
2027
+ <option key={n} value={n}>
2028
+ {n}
2029
+ </option>
2030
+ ))}
2031
+ </select>
2032
+ ) : null}
2033
+ </>
2034
+ );
2035
+ })()}
2036
+ </div>
2037
+ <div className="text-xs text-stone-500 mt-2">
2038
+ Each shuttle can accommodate up to {RESOURCE_CAPACITY} passengers.
2039
+ {resourceCount > 1 && (
2040
+ <span>
2041
+ {' '}
2042
+ You&apos;ll need {resourceCount} shuttles for {passengerCount}{' '}
2043
+ passengers.
2044
+ </span>
2045
+ )}
2046
+ </div>
2047
+ {(() => {
2048
+ const maxVacancies = selectedAvailability?.vacancies || 0;
2049
+ if (maxVacancies > 0 && !isSpecialRequestMode) {
2050
+ return (
2051
+ <div className="mt-3 pt-3 border-t border-stone-200 text-right">
2052
+ <button
2053
+ type="button"
2054
+ onClick={() => {
2055
+ const initial = maxVacancies + 1;
2056
+ setIsSpecialRequestMode(true);
2057
+ setPassengerCount(initial);
2058
+ setSpecialRequestInputValue(String(initial));
2059
+ }}
2060
+ className={`${styles.specialRequestLink} text-emerald-600 hover:text-emerald-700 underline`}
2061
+ >
2062
+ Special request for more than {maxVacancies} passengers
2063
+ </button>
2064
+ </div>
2065
+ );
2066
+ }
2067
+ if (isSpecialRequestMode && passengerCount > maxVacancies) {
2068
+ return (
2069
+ <div className="mt-3 pt-3 border-t border-stone-200">
2070
+ <p className="text-sm text-amber-700 bg-amber-50 p-3 rounded-lg">
2071
+ The team will review your request to ensure we can pull{' '}
2072
+ {resourceCount} {resourceCount === 1 ? 'shuttle' : 'shuttles'}{' '}
2073
+ from our fleet. You&apos;ll pay the full amount for {resourceCount}{' '}
2074
+ {resourceCount === 1 ? 'shuttle' : 'shuttles'}; we&apos;ve reserved{' '}
2075
+ {billableResourceCount} at standard capacity and the team will add the rest.
2076
+ </p>
2077
+ </div>
2078
+ );
2079
+ }
2080
+ return null;
2081
+ })()}
2082
+ </div>
2083
+ </div>
2084
+ )}
2085
+
2086
+ {selectedOption && selectedAvailability && passengerCount > 0 && (
2087
+ <div className={styles.section}>
2088
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2089
+ {t('booking.selectPickupTime')}
2090
+ </label>
2091
+ {(suggestedStartTimes.length > 0 || isCustomTimeMode) && (
2092
+ <div className="flex flex-wrap gap-2">
2093
+ {suggestedStartTimes.map((time) => {
2094
+ const isSelected = !isCustomTimeMode && selectedStartTime === time;
2095
+ const [hours, minutes] = time.split(':').map(Number);
2096
+ const timeInTz = fromZonedTime(new Date(2000, 0, 1, hours, minutes, 0), companyTimezone);
2097
+ const displayTime = formatInTimeZone(timeInTz, companyTimezone, 'h:mm a');
2098
+ return (
2099
+ <button
2100
+ key={time}
2101
+ type="button"
2102
+ onClick={() => handleStartTimeSelect(time)}
2103
+ className={`${styles.btnTime} private-shuttle-btn-time ${
2104
+ isSelected ? styles.btnTimeSelected : styles.btnTimeDefault
2105
+ }`}
2106
+ >
2107
+ {displayTime}
2108
+ </button>
2109
+ );
2110
+ })}
2111
+ {suggestedStartTimes.length > 0 && (
2112
+ <button
2113
+ type="button"
2114
+ onClick={handleCustomTimeRequest}
2115
+ className={`${styles.btnTime} private-shuttle-btn-time ${
2116
+ isCustomTimeMode ? styles.btnTimeSelected : styles.btnTimeDefault
2117
+ }`}
2118
+ >
2119
+ {t('booking.requestDifferentTime')}
2120
+ </button>
2121
+ )}
2122
+ </div>
2123
+ )}
2124
+ {(isCustomTimeMode || suggestedStartTimes.length === 0) && (
2125
+ <div className="mt-3">
2126
+ <label
2127
+ htmlFor="custom-pickup-time"
2128
+ className={styles.labelSecondary}
2129
+ >
2130
+ {suggestedStartTimes.length === 0
2131
+ ? t('booking.preferredPickupTime')
2132
+ : t('booking.requestDifferentTime')}
2133
+ </label>
2134
+ <input
2135
+ id="custom-pickup-time"
2136
+ type="time"
2137
+ value={selectedStartTime}
2138
+ onChange={(e) => handleCustomTimeChange(e.target.value)}
2139
+ className={styles.inputTime}
2140
+ />
2141
+ </div>
2142
+ )}
2143
+ </div>
2144
+ )}
2145
+
2146
+ {selectedOption && passengerCount > 0 && product.itineraryBuilder && (
2147
+ <div className={`${styles.section} itinerary-builder-section`}>
2148
+ <ItineraryBuilder
2149
+ destinations={product.itineraryBuilder.destinations}
2150
+ optionBlacklist={
2151
+ privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist || []
2152
+ }
2153
+ selectedDestinationIds={draftItineraryDestinations}
2154
+ planningNotes={draftItineraryPlanningNotes}
2155
+ onDestinationsChange={setDraftItineraryDestinations}
2156
+ onPlanningNotesChange={setDraftItineraryPlanningNotes}
2157
+ />
2158
+ {isAdmin && (
2159
+ <div className={styles.adminBox}>
2160
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2161
+ Add on hours (admin)
2162
+ </label>
2163
+ <p className="text-sm text-stone-600 mb-3">
2164
+ Each additional hour adds{' '}
2165
+ {formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency, locale as 'en' | 'fr')}{' '}
2166
+ and extends the tour duration.
2167
+ </p>
2168
+ <div className="flex items-center gap-3">
2169
+ <button
2170
+ type="button"
2171
+ onClick={() => setAdditionalHoursCount((c) => Math.max(0, c - 1))}
2172
+ disabled={additionalHoursCount <= 0}
2173
+ className={styles.qtyBtn}
2174
+ >
2175
+
2176
+ </button>
2177
+ <span className="w-8 text-center font-medium tabular-nums">
2178
+ {additionalHoursCount}
2179
+ </span>
2180
+ <button
2181
+ type="button"
2182
+ onClick={() => setAdditionalHoursCount((c) => c + 1)}
2183
+ className={styles.qtyBtn}
2184
+ >
2185
+ +
2186
+ </button>
2187
+ <span className="text-sm text-stone-600">
2188
+ {additionalHoursCount === 0
2189
+ ? 'No extra hours'
2190
+ : additionalHoursCount === 1
2191
+ ? `+1 hour • ${formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency, locale as 'en' | 'fr')}`
2192
+ : `+${additionalHoursCount} hours • ${formatCurrencyAmount(additionalHoursAmount, currency, locale as 'en' | 'fr')}`}
2193
+ </span>
2194
+ </div>
2195
+ </div>
2196
+ )}
2197
+ </div>
2198
+ )}
2199
+
2200
+ {selectedOption && passengerCount > 0 && (
2201
+ <div className={styles.section}>
2202
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2203
+ Safety seats for kids
2204
+ </label>
2205
+ <p className="text-sm text-stone-500 mb-2">
2206
+ How many child safety seats do you need?
2207
+ </p>
2208
+ <div className="flex items-center gap-2">
2209
+ <button
2210
+ type="button"
2211
+ onClick={() => setChildSafetySeatsCount((c) => Math.max(0, c - 1))}
2212
+ disabled={childSafetySeatsCount <= 0}
2213
+ className={styles.qtyBtn}
2214
+ >
2215
+
2216
+ </button>
2217
+ <span className="w-8 text-center font-medium tabular-nums">
2218
+ {childSafetySeatsCount}
2219
+ </span>
2220
+ <button
2221
+ type="button"
2222
+ onClick={() =>
2223
+ setChildSafetySeatsCount((c) =>
2224
+ Math.min(passengerCount, c + 1)
2225
+ )
2226
+ }
2227
+ disabled={childSafetySeatsCount >= passengerCount}
2228
+ className={styles.qtyBtn}
2229
+ >
2230
+ +
2231
+ </button>
2232
+ </div>
2233
+ </div>
2234
+ )}
2235
+
2236
+ {selectedOption && passengerCount > 0 && (
2237
+ <div className={styles.section}>
2238
+ <label
2239
+ htmlFor="food-restrictions"
2240
+ className={`${styles.sectionLabel} private-shuttle-section-label`}
2241
+ >
2242
+ Food restrictions
2243
+ </label>
2244
+ <p className="text-sm text-stone-500 mb-2">
2245
+ Shuttle includes croissants, coffee, tea, hot chocolate, trail snacks.
2246
+ </p>
2247
+ <textarea
2248
+ id="food-restrictions"
2249
+ value={foodRestrictions}
2250
+ onChange={(e) => setFoodRestrictions(e.target.value)}
2251
+ placeholder="Any dietary restrictions or allergies?"
2252
+ rows={2}
2253
+ className={styles.input}
2254
+ />
2255
+ </div>
2256
+ )}
2257
+
2258
+ {selectedOption && passengerCount > 0 && addOns.length > 0 && (
2259
+ <div className={styles.section}>
2260
+ {draftItineraryDestinations.includes('emerald_lake') && (() => {
2261
+ const lunchAddOn = addOns.find((a) => a.addOnId === 'addon_el_lunch');
2262
+ if (!lunchAddOn || !canUseMealDrinkSelector(lunchAddOn)) return null;
2263
+ return (
2264
+ <MealDrinkAddOnSelector
2265
+ addOn={lunchAddOn}
2266
+ selections={addOnSelections}
2267
+ onSelectionsChange={setAddOnSelections}
2268
+ currency={currency}
2269
+ locale={locale}
2270
+ />
2271
+ );
2272
+ })()}
2273
+ {(() => {
2274
+ const animalsAddOn = addOns.find((a) => a.addOnId === 'addon_animals');
2275
+ if (!animalsAddOn) return null;
2276
+ const isAnimalsSelected = addOnSelections.some(
2277
+ (s) => s.addOnId === 'addon_animals'
2278
+ );
2279
+ return (
2280
+ <div>
2281
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2282
+ Traveling with animals?
2283
+ </label>
2284
+ <p className="text-sm text-stone-500 mb-2">
2285
+ {animalsAddOn.description ||
2286
+ 'Cleaning fee for traveling with animals'}
2287
+ </p>
2288
+ <div className="flex flex-wrap gap-2">
2289
+ <button
2290
+ type="button"
2291
+ onClick={() => {
2292
+ setAddOnSelections((prev) =>
2293
+ prev.filter((s) => s.addOnId !== 'addon_animals')
2294
+ );
2295
+ }}
2296
+ className={`${styles.btnTime} private-shuttle-btn-time ${
2297
+ !isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
2298
+ }`}
2299
+ >
2300
+ No animals
2301
+ </button>
2302
+ <button
2303
+ type="button"
2304
+ onClick={() => {
2305
+ setAddOnSelections((prev) => {
2306
+ if (isAnimalsSelected) return prev;
2307
+ return [...prev, { addOnId: 'addon_animals', quantity: 1 }];
2308
+ });
2309
+ }}
2310
+ className={`${styles.btnTime} private-shuttle-btn-time flex items-center gap-2 ${
2311
+ isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
2312
+ }`}
2313
+ >
2314
+ <span>Yes, traveling with animals</span>
2315
+ <span className="text-sm font-semibold opacity-90">
2316
+ +
2317
+ {formatCurrencyAmount(
2318
+ animalsAddOn.price || 0,
2319
+ currency,
2320
+ locale as 'en' | 'fr'
2321
+ )}
2322
+ </span>
2323
+ </button>
2324
+ </div>
2325
+ </div>
2326
+ );
2327
+ })()}
2328
+ </div>
2329
+ )}
2330
+
2331
+ {selectedOption &&
2332
+ passengerCount > 0 &&
2333
+ product.pickupLocations &&
2334
+ product.pickupLocations.length > 0 && (
2335
+ <div id="pickup-location-section" className={styles.section}>
2336
+ {pickupLocationId || customPickupAddress || pickupLocationSkipped ? (
2337
+ <div className="space-y-4">
2338
+ <div className="flex items-center justify-between">
2339
+ <div>
2340
+ <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2341
+ {t('pickup.pickupLocation') || 'Pickup Location'}
2342
+ </label>
2343
+ {pickupLocationSkipped ? (
2344
+ <p className="text-sm text-stone-900">
2345
+ {t('booking.pickupLocationUnknown')}
2346
+ </p>
2347
+ ) : customPickupAddress ? (
2348
+ <p className="text-sm text-stone-900">{customPickupAddress}</p>
2349
+ ) : (
2350
+ <>
2351
+ <p className="text-sm text-stone-900">
2352
+ {selectedPickupLocation?.name}
2353
+ </p>
2354
+ <p className="text-xs text-stone-500">
2355
+ {selectedPickupLocation?.address}
2356
+ </p>
2357
+ </>
2358
+ )}
2359
+ </div>
2360
+ <button
2361
+ type="button"
2362
+ onClick={() => {
2363
+ setPickupLocationId(null);
2364
+ setCustomPickupAddress(null);
2365
+ setPickupLocationSkipped(false);
2366
+ setSelectedStartTime('');
2367
+ setIsCustomTimeMode(false);
2368
+ }}
2369
+ className={styles.changeBtn}
2370
+ >
2371
+ {t('common.change') || 'Change'}
2372
+ </button>
2373
+ </div>
2374
+ </div>
2375
+ ) : (
2376
+ <PickupLocationSelector
2377
+ pickupLocations={product.pickupLocations}
2378
+ selectedLocationId={pickupLocationId}
2379
+ selectedCustomAddress={customPickupAddress}
2380
+ highlightedPickupLocationIds={highlightedPickupLocationIds}
2381
+ allowCustomLocation
2382
+ restrictCustomLocationToServiceArea
2383
+ isSkipped={pickupLocationSkipped}
2384
+ destinations={product.destinations}
2385
+ onLocationSelect={(locationId, customLocation) => {
2386
+ setError('');
2387
+ if (customLocation) {
2388
+ setPickupLocationId(null);
2389
+ setCustomPickupAddress(customLocation.address);
2390
+ setPickupLocationSkipped(false);
2391
+ } else {
2392
+ setPickupLocationId(locationId);
2393
+ setCustomPickupAddress(null);
2394
+ setPickupLocationSkipped(false);
2395
+ }
2396
+ }}
2397
+ onSkip={() => {
2398
+ setPickupLocationSkipped(true);
2399
+ setPickupLocationId(null);
2400
+ setCustomPickupAddress(null);
2401
+ setError('');
2402
+ }}
2403
+ />
2404
+ )}
2405
+ </div>
2406
+ )}
2407
+
2408
+ {selectedStartTime && passengerCount > 0 && (
2409
+ <div className={`${styles.section} mt-6`}>
2410
+ <PriceSummary
2411
+ lines={[
2412
+ {
2413
+ kind: 'line',
2414
+ label: resourceCount > 1 ? `Shuttle x ${resourceCount}` : 'Shuttle',
2415
+ amount: basePrice,
2416
+ type: 'ticket',
2417
+ },
2418
+ ...addOnSelections
2419
+ .map((sel) => {
2420
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
2421
+ if (!addOn) return null;
2422
+ const base = addOn.price ?? 0;
2423
+ const hasVariant =
2424
+ (addOn.variantType === 'single_choice' ||
2425
+ addOn.variantType === 'multi_quantity') &&
2426
+ sel.variantId;
2427
+ const adj = hasVariant
2428
+ ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ??
2429
+ 0)
2430
+ : 0;
2431
+ return {
2432
+ kind: 'line' as const,
2433
+ label: addOn.name,
2434
+ amount: (base + adj) * (sel.quantity ?? 1),
2435
+ type: 'fee' as const,
2436
+ };
2437
+ })
2438
+ .filter(Boolean) as Array<{ kind: 'line'; label: string; amount: number; type: 'fee' }>,
2439
+ ...(additionalHoursAmount > 0
2440
+ ? [
2441
+ {
2442
+ kind: 'line' as const,
2443
+ label:
2444
+ additionalHoursCount === 1
2445
+ ? 'Additional hour'
2446
+ : `Additional hours (${additionalHoursCount})`,
2447
+ amount: additionalHoursAmount,
2448
+ type: 'fee' as const,
2449
+ },
2450
+ ]
2451
+ : []),
2452
+ ...(cancellationPolicyFee > 0
2453
+ ? [
2454
+ {
2455
+ kind: 'line' as const,
2456
+ label: selectedCancellationPolicy?.label ?? 'Cancellation',
2457
+ amount: cancellationPolicyFee,
2458
+ type: 'cancellation' as const,
2459
+ },
2460
+ ]
2461
+ : []),
2462
+ ]}
2463
+ total={depositInfo ? depositInfo.depositAmount : totalPrice}
2464
+ currency={currency}
2465
+ locale={locale as 'en' | 'fr'}
2466
+ taxAmount={taxAmount}
2467
+ taxRate={taxRate}
2468
+ size="sm"
2469
+ t={t}
2470
+ depositMode={
2471
+ depositInfo
2472
+ ? {
2473
+ totalLabel: t('booking.deposit') || 'Deposit',
2474
+ balanceAmount: depositInfo.balanceAmount,
2475
+ fullTotalAmount: depositInfo.totalPrice,
2476
+ }
2477
+ : undefined
2478
+ }
2479
+ hideSubtotal={!!depositInfo}
2480
+ />
2481
+ {depositInfo && (
2482
+ <div className="deposit-notice mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
2483
+ <p className="text-xs font-medium">
2484
+ {(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice'
2485
+ ? t('booking.depositPaymentNotice')
2486
+ : null) ?? "You're paying the deposit today."}
2487
+ </p>
2488
+ <p className="mt-1 text-xs text-amber-700/90">
2489
+ {privateShuttleConfig?.balanceChargeDaysBefore && privateShuttleConfig.balanceChargeDaysBefore > 0
2490
+ ? (t('booking.balanceChargeNotice', { days: privateShuttleConfig.balanceChargeDaysBefore }) !== 'booking.balanceChargeNotice'
2491
+ ? t('booking.balanceChargeNotice', { days: privateShuttleConfig.balanceChargeDaysBefore })
2492
+ : `The remaining balance will be charged ${privateShuttleConfig.balanceChargeDaysBefore} days before your booking. You can also pay it earlier from your Manage Booking page.`)
2493
+ : (t('booking.balancePayEarlier') !== 'booking.balancePayEarlier'
2494
+ ? t('booking.balancePayEarlier')
2495
+ : 'You can pay the remaining balance anytime from your Manage Booking page.')}
2496
+ </p>
2497
+ </div>
2498
+ )}
2499
+ </div>
2500
+ )}
2501
+
2502
+ {selectedStartTime && passengerCount > 0 && (
2503
+ <div className="border-t border-stone-200 pt-6 space-y-4">
2504
+ <div className="space-y-4">
2505
+ <>
2506
+ <div className={styles.contactGrid}>
2507
+ <div>
2508
+ <label className={styles.sectionLabel}>
2509
+ {t('booking.firstName')} <span className={styles.required}>*</span>
2510
+ </label>
2511
+ <input
2512
+ type="text"
2513
+ value={firstName}
2514
+ onChange={(e) => {
2515
+ setFirstName(e.target.value);
2516
+ setError('');
2517
+ }}
2518
+ placeholder={t('booking.firstNamePlaceholder')}
2519
+ className={styles.input}
2520
+ />
2521
+ </div>
2522
+ <div>
2523
+ <label className={styles.sectionLabel}>
2524
+ {t('booking.lastName')} <span className={styles.required}>*</span>
2525
+ </label>
2526
+ <input
2527
+ type="text"
2528
+ value={lastName}
2529
+ onChange={(e) => {
2530
+ setLastName(e.target.value);
2531
+ setError('');
2532
+ }}
2533
+ placeholder={t('booking.lastNamePlaceholder')}
2534
+ className={styles.input}
2535
+ />
2536
+ </div>
2537
+ </div>
2538
+ <div>
2539
+ <label className={styles.sectionLabel}>
2540
+ {t('common.emailForConfirmation')} <span className={styles.required}>*</span>
2541
+ </label>
2542
+ <input
2543
+ type="email"
2544
+ value={email}
2545
+ onChange={(e) => {
2546
+ setEmail(e.target.value);
2547
+ setError('');
2548
+ }}
2549
+ placeholder={t('common.emailPlaceholder')}
2550
+ className={styles.input}
2551
+ />
2552
+ </div>
2553
+ </>
2554
+ {pricingConfig?.cancellationPolicies &&
2555
+ pricingConfig.cancellationPolicies.length > 0 && (
2556
+ <div>
2557
+ <CancellationPolicySelector
2558
+ policies={[...pricingConfig.cancellationPolicies].sort((a, b) => {
2559
+ const feeA = a.feeByCurrency[currency] ?? 0;
2560
+ const feeB = b.feeByCurrency[currency] ?? 0;
2561
+ return feeA - feeB;
2562
+ })}
2563
+ selectedPolicyId={cancellationPolicyId}
2564
+ currency={currency}
2565
+ locale={locale}
2566
+ t={t}
2567
+ onPolicySelect={setCancellationPolicyId}
2568
+ rowSubtitle={
2569
+ <>
2570
+ <b>Your deposit today is 100% refundable</b> — only once we align on an itinerary that you approve, will your deposit be locked in.
2571
+ </>
2572
+ }
2573
+ />
2574
+ </div>
2575
+ )}
2576
+ {isAdmin && (
2577
+ <div className="space-y-2 p-4 bg-amber-50/50 rounded-lg">
2578
+ <label className="flex items-center gap-2">
2579
+ <input
2580
+ type="checkbox"
2581
+ checked={skipConfirmationCommunications}
2582
+ onChange={(e) => setSkipConfirmationCommunications(e.target.checked)}
2583
+ />
2584
+ <span className="text-sm">
2585
+ Don&apos;t send confirmation for this booking
2586
+ </span>
2587
+ </label>
2588
+ <label className="flex items-center gap-2">
2589
+ <input
2590
+ type="checkbox"
2591
+ checked={disableAutoCommunications}
2592
+ onChange={(e) => setDisableAutoCommunications(e.target.checked)}
2593
+ />
2594
+ <span className="text-sm">Disable all auto communications</span>
2595
+ </label>
2596
+ </div>
2597
+ )}
2598
+ <div className="mt-4 p-4 bg-stone-50 rounded-lg border-2 border-stone-300">
2599
+ <TermsAcceptance
2600
+ checked={termsAccepted}
2601
+ onChange={(checked) => {
2602
+ setTermsAccepted(checked);
2603
+ setTermsAcceptedAt(checked ? new Date().toISOString() : null);
2604
+ }}
2605
+ t={t}
2606
+ />
2607
+ </div>
2608
+ </div>
2609
+ {error && (
2610
+ <div className={styles.errorBox}>
2611
+ {error}
2612
+ </div>
2613
+ )}
2614
+ {flowUi?.partnerAttributionSummary ? (
2615
+ <div className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
2616
+ <p className="m-0 text-sm text-blue-900">{flowUi.partnerAttributionSummary}</p>
2617
+ {flowUi.partnerAttributionConfirmLabel ? (
2618
+ <label className="mt-2 flex cursor-pointer items-start gap-2">
2619
+ <input
2620
+ type="checkbox"
2621
+ checked={partnerAttributionConfirmed}
2622
+ onChange={(e) => setPartnerAttributionConfirmed(e.target.checked)}
2623
+ />
2624
+ <span className="text-xs font-medium leading-5 text-blue-800">
2625
+ {flowUi.partnerAttributionConfirmLabel}
2626
+ </span>
2627
+ </label>
2628
+ ) : null}
2629
+ </div>
2630
+ ) : null}
2631
+ {!showCheckoutModal && !showAdminPaymentChoice ? (
2632
+ <>
2633
+ <button
2634
+ type="button"
2635
+ onClick={handleCheckout}
2636
+ disabled={
2637
+ loading ||
2638
+ !termsAccepted ||
2639
+ Boolean(flowUi?.partnerAttributionConfirmLabel && !partnerAttributionConfirmed)
2640
+ }
2641
+ className={styles.submitBtn}
2642
+ >
2643
+ {loading
2644
+ ? (t('booking.creatingReservation') || 'Creating reservation...')
2645
+ : (flowUi?.partnerDeferredInvoiceSubmitLabel ||
2646
+ t('booking.continueToPayment') ||
2647
+ 'Continue to Payment')}{' '}
2648
+ ({formatCurrencyAmount(depositInfo ? depositInfo.depositAmount : totalPrice, currency, locale as 'en' | 'fr')})
2649
+ </button>
2650
+ <p className={styles.secureNote}>
2651
+ {t('booking.securePayment') || 'Secure payment powered by Stripe'}
2652
+ </p>
2653
+ </>
2654
+ ) : null}
2655
+ </div>
2656
+ )}
2657
+ </div>
2658
+ </>
2659
+ )}
2660
+ </div>
2661
+ );
2662
+ }