@ticketboothapp/booking 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/package.json +2 -1
  2. package/src/app/photo-sessions/photo-packages.ts +75 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2072 -354
  8. package/src/components/BookingWidget.tsx +28 -248
  9. package/src/components/JobApplicationDialog.module.css +440 -0
  10. package/src/components/JobApplicationDialog.tsx +620 -0
  11. package/src/components/ManageBookingView.tsx +28 -36
  12. package/src/components/PhoneInputWithCountry.module.css +131 -0
  13. package/src/components/PhoneInputWithCountry.tsx +44 -0
  14. package/src/components/PickupLocationDialog.module.css +360 -0
  15. package/src/components/PickupLocationDialog.tsx +357 -0
  16. package/src/components/PickupLocationMap.tsx +110 -0
  17. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  18. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  19. package/src/components/accordion.css +27 -0
  20. package/src/components/accordion.tsx +29 -0
  21. package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
  22. package/src/components/analytics/AnalyticsScripts.tsx +106 -0
  23. package/src/components/analytics/CookieConsentBanner.css +86 -0
  24. package/src/components/analytics/CookieConsentBanner.tsx +102 -0
  25. package/src/components/booking/AddOnsSection.module.css +10 -0
  26. package/src/components/booking/AddOnsSection.tsx +184 -0
  27. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  28. package/src/components/booking/BookingDialog.module.css +643 -0
  29. package/src/components/booking/BookingDialog.tsx +356 -0
  30. package/src/components/booking/BookingFlow.tsx +4385 -0
  31. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  32. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  33. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  34. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  35. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  36. package/src/components/booking/BookingProductGrid.module.css +359 -0
  37. package/src/components/booking/BookingProductGrid.tsx +497 -0
  38. package/src/components/booking/Calendar.module.css +616 -0
  39. package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
  40. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  41. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  42. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  43. package/src/components/booking/CheckoutForm.module.css +244 -0
  44. package/src/components/booking/CheckoutForm.tsx +364 -0
  45. package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
  46. package/src/components/booking/DapFlowCollage.tsx +88 -0
  47. package/src/components/booking/DapTourDescription.tsx +35 -0
  48. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  49. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  50. package/src/components/booking/InfoTooltip.tsx +108 -0
  51. package/src/components/booking/ItineraryBox.module.css +258 -0
  52. package/src/components/booking/ItineraryBox.tsx +550 -0
  53. package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
  54. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  55. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  56. package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
  57. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  58. package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
  59. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  60. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  61. package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
  62. package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
  63. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  64. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  65. package/src/components/booking/PromoCodeInput.module.css +166 -0
  66. package/src/components/booking/PromoCodeInput.tsx +99 -0
  67. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  68. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  69. package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
  70. package/src/components/booking/TicketSelector.module.css +164 -0
  71. package/src/components/booking/TicketSelector.tsx +199 -0
  72. package/src/components/booking/TourDescription.module.css +304 -0
  73. package/src/components/booking/TourDescription.tsx +273 -0
  74. package/src/components/booking/booking-flow-ui.ts +15 -1
  75. package/src/components/booking/booking-flow.css +944 -0
  76. package/src/components/bottom-sheet.module.css +78 -0
  77. package/src/components/bottom-sheet.tsx +60 -0
  78. package/src/components/breadcrumb.module.css +40 -0
  79. package/src/components/breadcrumb.tsx +36 -0
  80. package/src/components/button.css +245 -0
  81. package/src/components/button.tsx +152 -0
  82. package/src/components/client-bottom-sheet.tsx +14 -0
  83. package/src/components/colorable-svg.tsx +29 -0
  84. package/src/components/conditional-footer.tsx +27 -0
  85. package/src/components/contact-us.module.css +147 -0
  86. package/src/components/contact-us.tsx +49 -0
  87. package/src/components/email-signup.css +151 -0
  88. package/src/components/email-signup.tsx +63 -0
  89. package/src/components/faq-wrapper.module.css +47 -0
  90. package/src/components/faq-wrapper.tsx +15 -0
  91. package/src/components/footer.css +187 -0
  92. package/src/components/footer.tsx +143 -0
  93. package/src/components/global-simple-modal.tsx +33 -0
  94. package/src/components/google-review-summary.module.css +77 -0
  95. package/src/components/google-review-summary.tsx +50 -0
  96. package/src/components/hero-image.css +13 -0
  97. package/src/components/hero-image.tsx +44 -0
  98. package/src/components/image.css +29 -0
  99. package/src/components/image.tsx +113 -0
  100. package/src/components/language-aware-link.tsx +72 -0
  101. package/src/components/language-switcher.module.css +124 -0
  102. package/src/components/language-switcher.tsx +75 -0
  103. package/src/components/map-section.css +59 -0
  104. package/src/components/map-section.tsx +63 -0
  105. package/src/components/navbar.module.css +152 -0
  106. package/src/components/navbar.tsx +125 -0
  107. package/src/components/parallax-provider.tsx +11 -0
  108. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  109. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  110. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
  111. package/src/components/product-tag.module.css +30 -0
  112. package/src/components/product-tag.tsx +34 -0
  113. package/src/components/product-theme-pages/best-option.module.css +70 -0
  114. package/src/components/product-theme-pages/best-option.tsx +35 -0
  115. package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
  116. package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
  117. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  118. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  119. package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
  120. package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
  121. package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
  122. package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
  123. package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
  124. package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
  125. package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
  126. package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
  127. package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
  128. package/src/components/product-tile/image-only-product-tile.tsx +44 -0
  129. package/src/components/product-tile/product-tile-card.module.css +84 -0
  130. package/src/components/product-tile/product-tile-card.tsx +61 -0
  131. package/src/components/review-highlights-section.css +85 -0
  132. package/src/components/review-highlights-section.tsx +127 -0
  133. package/src/components/season-closure-overlay.module.css +99 -0
  134. package/src/components/season-closure-overlay.tsx +98 -0
  135. package/src/components/simple-modal.tsx +69 -0
  136. package/src/components/simple-top-of-fold.module.css +76 -0
  137. package/src/components/simple-top-of-fold.tsx +34 -0
  138. package/src/components/spacer.css +41 -0
  139. package/src/components/spacer.tsx +23 -0
  140. package/src/components/star-rating.module.css +74 -0
  141. package/src/components/star-rating.tsx +48 -0
  142. package/src/components/terms/TermsContent.tsx +178 -0
  143. package/src/components/title-subtitle.module.css +10 -0
  144. package/src/components/title-subtitle.tsx +30 -0
  145. package/src/components/translatable-reviews.tsx +75 -0
  146. package/src/components/value-pill.module.css +59 -0
  147. package/src/components/value-pill.tsx +46 -0
  148. package/src/components/value-props.css +185 -0
  149. package/src/components/value-props.tsx +88 -0
  150. package/src/constants/booking-guide-quiz.ts +64 -0
  151. package/src/constants/contact-info.ts +2 -0
  152. package/src/constants/faq.ts +44 -0
  153. package/src/constants/images.ts +556 -0
  154. package/src/constants/json-ld/faq-json-ld.tsx +170 -0
  155. package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
  156. package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
  157. package/src/constants/json-ld/organization-json-ld.tsx +62 -0
  158. package/src/constants/json-ld/page-json-ld.tsx +6 -0
  159. package/src/constants/json-ld/product-json-ld.tsx +154 -0
  160. package/src/constants/json-ld/review-json-ld.tsx +377 -0
  161. package/src/constants/navigation-links/footer-links.ts +48 -0
  162. package/src/constants/navigation-links/nav-bar-links.ts +41 -0
  163. package/src/constants/navigation-links/navigation-link.ts +6 -0
  164. package/src/constants/pill-values.ts +210 -0
  165. package/src/constants/products.ts +155 -0
  166. package/src/constants/quiz-recommendations.ts +506 -0
  167. package/src/constants/reviews.ts +75 -0
  168. package/src/constants/staff.ts +197 -0
  169. package/src/constants/value-props.ts +58 -0
  170. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  171. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  172. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  173. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  174. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  175. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  176. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  177. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  178. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  179. package/src/data/product-descriptions/private-tour.en.json +80 -0
  180. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  181. package/src/data/products-config.json +101 -0
  182. package/src/hooks/use-bottom-sheet.tsx +15 -0
  183. package/src/hooks/use-simple-modal.tsx +27 -0
  184. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  185. package/src/hooks/useEmailSubscription.tsx +103 -0
  186. package/src/hooks/useEmbeddedInIframe.ts +16 -0
  187. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  188. package/src/hooks/useQuiz.tsx +210 -0
  189. package/src/index.ts +27 -2
  190. package/src/lib/analytics.ts +197 -0
  191. package/src/lib/booking/booking-source.ts +20 -2
  192. package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
  193. package/src/lib/booking/correlation-id.ts +46 -0
  194. package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
  195. package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
  196. package/src/lib/booking/itinerary-display.ts +36 -0
  197. package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
  198. package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
  199. package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
  200. package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
  201. package/src/lib/booking/normalize-booking-product-id.ts +7 -0
  202. package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
  203. package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
  204. package/src/lib/booking/product-option-id.ts +35 -0
  205. package/src/lib/booking/source-metadata.ts +72 -7
  206. package/src/lib/booking/sunday-week.ts +14 -0
  207. package/src/lib/booking/trace-context.ts +62 -0
  208. package/src/lib/booking-api.ts +1793 -0
  209. package/src/lib/{constants.ts → booking-constants.ts} +11 -5
  210. package/src/lib/booking-types.ts +36 -0
  211. package/src/lib/currency.ts +38 -45
  212. package/src/lib/dap-descriptions.ts +50 -0
  213. package/src/lib/dap-itinerary-preview.ts +315 -0
  214. package/src/lib/dependent-add-on-api.ts +434 -0
  215. package/src/lib/env.ts +89 -5
  216. package/src/lib/firebase.ts +20 -0
  217. package/src/lib/job-application-api.ts +83 -0
  218. package/src/lib/manage-booking-embed-print.ts +16 -0
  219. package/src/lib/manage-booking-post-checkout.ts +68 -0
  220. package/src/lib/photo-dap-config.ts +228 -0
  221. package/src/lib/pickup/map-utils.ts +56 -0
  222. package/src/lib/pickup/marker-icons.ts +19 -0
  223. package/src/lib/product-descriptions.ts +66 -0
  224. package/src/lib/products-config.ts +73 -0
  225. package/src/providers/booking-dialog-provider.tsx +107 -38
  226. package/src/providers/bottom-sheet-provider.tsx +40 -0
  227. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  228. package/src/radius.css +5 -0
  229. package/src/spacing.css +7 -0
  230. package/src/strings/en.json +1774 -0
  231. package/src/strings/es.json +1573 -0
  232. package/src/strings/fr.json +1573 -0
  233. package/src/strings/index.js +23 -0
  234. package/src/text-style.css +97 -0
  235. package/src/types/fareharbor.d.ts +12 -0
  236. package/src/types/quiz.ts +59 -0
  237. package/src/utils/currency-converter.ts +101 -0
  238. package/src/components/BookingFlow.tsx +0 -2952
  239. package/src/components/LanguageSwitcher.tsx +0 -30
  240. package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
  241. package/src/components/ProductList.tsx +0 -78
  242. package/src/components/WhatsAppPhoneInput.tsx +0 -224
  243. package/src/components/index.ts +0 -31
  244. package/src/lib/api.ts +0 -801
  245. package/src/lib/booking-api-auth.ts +0 -9
  246. package/src/lib/checkout-breakdown.test.ts +0 -70
  247. package/src/types/google-maps.d.ts +0 -2
  248. /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
  249. /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
  250. /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
  251. /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
  252. /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
  253. /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
  254. /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
  255. /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
@@ -1,2290 +0,0 @@
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 } from 'date-fns-tz';
6
- import {
7
- getAvailabilities,
8
- createReservation,
9
- createPaymentIntent,
10
- confirmFreeBooking,
11
- confirmBookingWithoutPayment,
12
- getAddOns,
13
- validatePromoCode,
14
- getPromoDiscount,
15
- type Product,
16
- type Availability,
17
- type ItineraryDisplayStep,
18
- type AddOn,
19
- ItineraryStepType,
20
- } from '@/lib/api';
21
- import {
22
- EARLIEST_AVAILABILITY_DATE,
23
- LATEST_AVAILABILITY_DATE,
24
- INITIAL_FETCH_WEEKS,
25
- } from '@/lib/constants';
26
- import { formatCurrencyAmount } from '@/lib/currency';
27
- import { formatBookingRefForDisplay } from '@/lib/booking-ref';
28
- import { buildCheckoutBreakdown } from '@/lib/checkout-breakdown';
29
- import type { PricingConfig, PrecomputedPricesByCategory } from '@/lib/api';
30
- import { Calendar } from './Calendar';
31
- import { PickupLocationSelector } from './PickupLocationSelector';
32
- import { useTranslations, useLocale } from '@/lib/i18n';
33
- import { type Currency } from './CurrencySwitcher';
34
- import { useCompanyTimezone } from '@/contexts/CompanyContext';
35
- import { useBookingApp } from '@/contexts/BookingAppContext';
36
- import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
37
- import { Check, X } from 'lucide-react';
38
- import { PriceSummary } from './PriceSummary';
39
- import { TermsAcceptance } from './TermsAcceptance';
40
- import { ItineraryBuilder } from './ItineraryBuilder';
41
- import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
42
-
43
- interface PrivateShuttleBookingFlowProps {
44
- product: Product;
45
- onBack: () => void;
46
- currency: Currency;
47
- /**
48
- * Optional callback called when reservation is successfully created (before checkout redirect)
49
- * If provided, indicates we're in an embedded context (e.g., provider dashboard)
50
- */
51
- onSuccess?: (data: { reservationReference: string }) => void;
52
- /** Change mode: pre-fill from existing booking and call onChangeBooking on submit */
53
- initialBooking?: {
54
- bookingReference: string;
55
- productId: string;
56
- dateTime: string;
57
- originalTotalAmount?: number;
58
- originalCurrency?: string;
59
- bookingItems: Array<{ category: string; count: number }>;
60
- returnAvailabilityId?: string | null;
61
- pickupLocationId?: string | null;
62
- travelerHotel?: string | null;
63
- startTime?: string | null;
64
- privateShuttleDetails?: { passengerCount?: number };
65
- cancellationPolicyId?: string | null;
66
- promoCode?: string | null;
67
- /** Current additional billable hours on the booking (change mode pre-fill). */
68
- additionalHoursCount?: number | null;
69
- addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
70
- };
71
- onChangeBooking?: (data: {
72
- productId: string;
73
- dateTime: string;
74
- bookingItems: Array<{ category: string; count: number }>;
75
- returnAvailabilityId?: string | null;
76
- pickupLocationId?: string | null;
77
- travelerHotel?: string | null;
78
- startTime?: string | null;
79
- passengerCount?: number | null;
80
- childSafetySeatsCount?: number | null;
81
- foodRestrictions?: string | null;
82
- addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
83
- cancellationPolicyId?: string | null;
84
- promoCode?: string | null;
85
- newTotalAmount?: number;
86
- keepOriginalPrice?: boolean;
87
- /** Billable extra hours; sent on every private shuttle change so backend can update duration and total. */
88
- additionalHoursCount?: number | null;
89
- }) => Promise<void>;
90
- }
91
-
92
- export function PrivateShuttleBookingFlow({ product, onBack, currency, onSuccess, initialBooking, onChangeBooking }: PrivateShuttleBookingFlowProps) {
93
- const { t } = useTranslations();
94
- const { locale } = useLocale();
95
- const companyTimezone = useCompanyTimezone(); // Get timezone from context
96
- const { permissions, onShowManage, getSuccessUrl } = useBookingApp();
97
- const isAdmin = permissions.viewerRole === 'admin';
98
- const [availabilities, setAvailabilities] = useState<Availability[]>([]);
99
- const [selectedDate, setSelectedDate] = useState<string>('');
100
- const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
101
- const [selectedOption, setSelectedOption] = useState<string>('');
102
- const [selectedStartTime, setSelectedStartTime] = useState<string>('');
103
- const [isCustomTimeMode, setIsCustomTimeMode] = useState(false);
104
- const isChangeMode = !!(initialBooking && onChangeBooking);
105
- const [passengerCount, setPassengerCount] = useState<number>(initialBooking?.privateShuttleDetails?.passengerCount ?? 1);
106
- const [email, setEmail] = useState('');
107
- const [firstName, setFirstName] = useState('');
108
- const [lastName, setLastName] = useState('');
109
- const [pickupLocationId, setPickupLocationId] = useState<string | null>(initialBooking?.pickupLocationId ?? null);
110
- const [customPickupAddress, setCustomPickupAddress] = useState<string | null>(null);
111
- const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
112
- /** Draft itinerary for Private Shuttle (destinations, lunch stop, planning notes) */
113
- const [draftItineraryDestinations, setDraftItineraryDestinations] = useState<string[]>([]);
114
- const [draftItineraryPlanningNotes, setDraftItineraryPlanningNotes] = useState('');
115
- /** Child safety seats count (0 to passengerCount) */
116
- const [childSafetySeatsCount, setChildSafetySeatsCount] = useState(0);
117
- /** Food restrictions / dietary notes */
118
- const [foodRestrictions, setFoodRestrictions] = useState('');
119
- /** Add-on selections (lunch, animals, etc.) - single source of truth */
120
- const [addOnSelections, setAddOnSelections] = useState<Array<{ addOnId: string; variantId?: string; quantity?: number }>>(() =>
121
- initialBooking?.addOnSelections?.length
122
- ? initialBooking.addOnSelections.map((s) => ({
123
- addOnId: s.addOnId,
124
- variantId: s.variantId,
125
- quantity: s.quantity ?? 1,
126
- }))
127
- : []
128
- );
129
- /** Fetched add-ons for the selected option */
130
- const [addOns, setAddOns] = useState<AddOn[]>([]);
131
-
132
- // Get selected pickup location (memoized for performance)
133
- const selectedPickupLocation = useMemo(() =>
134
- pickupLocationId
135
- ? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
136
- : null,
137
- [pickupLocationId, product.pickupLocations]
138
- );
139
- const [loading, setLoading] = useState(false);
140
- const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
141
- const [error, setError] = useState('');
142
- const [showCheckoutModal, setShowCheckoutModal] = useState(false);
143
- const [termsAccepted, setTermsAccepted] = useState(false);
144
- const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
145
- const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
146
- const [checkoutModalData, setCheckoutModalData] = useState<{
147
- reservationReference: string;
148
- customerLastName?: string;
149
- ticketLines: CheckoutModalLineItem[];
150
- feeLineItems: { name: string; totalAmount: number; description?: string }[];
151
- returnPriceAdjustment: number;
152
- subtotal: number;
153
- tax: number;
154
- total: number;
155
- totalQuantity: number;
156
- isTaxIncludedInPrice: boolean;
157
- taxRate: number;
158
- /** When true, show deposit messaging (balance charged 7 days before or pay earlier). */
159
- isDepositPayment?: boolean;
160
- balanceChargeDaysBefore?: number;
161
- cancellationPolicyFee?: number;
162
- cancellationPolicyLabel?: string;
163
- promoDiscountAmount?: number;
164
- discountLabel?: string | null;
165
- } | null>(null);
166
- /** Admin only: skip sending confirmation at creation (provider dashboard). */
167
- const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
168
- /** Admin only: disable all auto communications for this booking (provider dashboard). */
169
- const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
170
- /** Admin + deposit: show choice to pay or confirm without payment. */
171
- const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
172
- /** Change mode: when true, apply change but keep original receipt/price — no charge or refund. */
173
- const [keepOriginalPrice, setKeepOriginalPrice] = useState(false);
174
- /** Promo/voucher (admin only): input, applied code, error, validating, discount amount */
175
- const [promoCodeInput, setPromoCodeInput] = useState(() => initialBooking?.promoCode?.trim() ?? '');
176
- const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(() => initialBooking?.promoCode?.trim() || null);
177
- const [promoCodeError, setPromoCodeError] = useState('');
178
- const [promoCodeValidating, setPromoCodeValidating] = useState(false);
179
- const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
180
- const [isGiftCard, setIsGiftCard] = useState(false);
181
- const [isVoucher, setIsVoucher] = useState(false);
182
- const lastValidatedInputRef = useRef<string | null>(null);
183
- /** Admin only: additional hours add-on ($170/hour, extends duration) */
184
- const [additionalHoursCount, setAdditionalHoursCount] = useState(
185
- () => initialBooking?.additionalHoursCount ?? 0
186
- );
187
-
188
- const ADDITIONAL_HOUR_PRICE = 170;
189
- /** Data for admin choice step (reservation, breakdown, etc.). */
190
- const [adminChoiceData, setAdminChoiceData] = useState<{
191
- reservationReference: string;
192
- checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
193
- depositAmount: number;
194
- balanceAmount: number;
195
- totalAmount: number;
196
- balanceChargeDaysBefore: number;
197
- itineraryDisplay?: ItineraryDisplayStep[];
198
- pickupLocationId?: string;
199
- } | null>(null);
200
- const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
201
- const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(null);
202
- /** When promo forces a cancellation policy (id + label from validate response). */
203
- const [forcedCancellationPolicyFromPromo, setForcedCancellationPolicyFromPromo] = useState<{ id: string; label: string } | null>(null);
204
- /** Precomputed prices from ticketbooth-product-prices (category -> currency -> price). Used for display; API prices are for GYG only. */
205
- const [precomputedPrices, setPrecomputedPrices] = useState<PrecomputedPricesByCategory | null>(null);
206
- /** Private Shuttle only: RESOURCE price per currency from API; use this, no conversion. */
207
- const [resourcePriceByCurrency, setResourcePriceByCurrency] = useState<Record<string, number> | null>(null);
208
- /** When allOptions: optionId -> (currency -> price) for each option. */
209
- const [resourcePriceByOption, setResourcePriceByOption] = useState<Record<string, Record<string, number>> | null>(null);
210
- const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
211
- const fetchingRef = useRef(false); // Prevent concurrent fetches
212
- const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
213
- const [refreshKey, setRefreshKey] = useState(0); // Bump to force refetch when tab regains focus (e.g. after reconciling on dashboard)
214
- const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
215
- const itineraryRef = useRef<HTMLDivElement>(null);
216
-
217
- // Get all active product options
218
- const activeOptions = useMemo(() => {
219
- return product.options?.filter(opt => opt.status === 'ACTIVE') || [];
220
- }, [product.options]);
221
-
222
- // Get selected option config
223
- const selectedOptionConfig = activeOptions.find(opt => opt.optionId === selectedOption);
224
- const privateShuttleConfig = selectedOptionConfig?.privateShuttleConfig;
225
-
226
- // Helper function to check if we need to fetch a date range
227
- const needsFetch = (start: Date, end: Date): boolean => {
228
- if (fetchedRangesRef.current.length === 0) return true;
229
-
230
- // Check if the requested range is fully covered by fetched ranges
231
- // For simplicity, check if any single fetched range fully covers the requested range
232
- return !fetchedRangesRef.current.some(range => {
233
- const rangeStart = range.start.getTime();
234
- const rangeEnd = range.end.getTime();
235
- const reqStart = start.getTime();
236
- const reqEnd = end.getTime();
237
-
238
- // Check if this fetched range fully covers the requested range
239
- return rangeStart <= reqStart && rangeEnd >= reqEnd;
240
- });
241
- };
242
-
243
- // Initialize visible range only once on mount
244
- useEffect(() => {
245
- if (!visibleRange) {
246
- // Initial load - fetch initial weeks (visible + buffer)
247
- const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS);
248
- setVisibleRange({ start: EARLIEST_AVAILABILITY_DATE, end: initialEnd });
249
- }
250
- }, [visibleRange]);
251
-
252
- // When user changes the product option (after selecting date), clear start time only.
253
- // Don't clear selectedAvailability - handleOptionSelect already sets the new one, and clearing
254
- // it here caused custom fields to flicker/disappear (effect ran after handleOptionSelect).
255
- const prevSelectedOptionRef = useRef<string | null>(null);
256
- useEffect(() => {
257
- if (selectedOption && prevSelectedOptionRef.current !== null && prevSelectedOptionRef.current !== selectedOption) {
258
- setSelectedStartTime('');
259
- setIsCustomTimeMode(false);
260
- }
261
- prevSelectedOptionRef.current = selectedOption;
262
- }, [selectedOption]);
263
-
264
- // Fetch add-ons when option is selected (for lunch package, animals, etc.)
265
- useEffect(() => {
266
- if (!selectedOption || !product.companyId) return;
267
- getAddOns(product.companyId, { productOptionId: selectedOption, preCheckout: true })
268
- .then(setAddOns)
269
- .catch(() => setAddOns([]));
270
- }, [selectedOption, product.companyId]);
271
-
272
- // Clear EL lunch add-on when Emerald Lake is deselected from destinations
273
- useEffect(() => {
274
- const hasEmeraldLake = draftItineraryDestinations.includes('emerald_lake');
275
- if (!hasEmeraldLake) {
276
- setAddOnSelections((prev) => prev.filter((s) => s.addOnId !== 'addon_el_lunch'));
277
- }
278
- }, [draftItineraryDestinations]);
279
-
280
- // When applied promo code changes, clear fetched ranges so we refetch with new pricing
281
- useEffect(() => {
282
- fetchedRangesRef.current = [];
283
- }, [appliedPromoCode]);
284
-
285
- // Fetch availabilities for visible range - date-first flow: use productId + allOptions to get all options
286
- useEffect(() => {
287
- if (activeOptions.length === 0) {
288
- setError('No active product options available');
289
- setLoadingAvailabilities(false);
290
- return;
291
- }
292
-
293
- if (!visibleRange) {
294
- // Wait for initial range to be set
295
- return;
296
- }
297
-
298
- async function fetchAvailabilities() {
299
- // Prevent concurrent fetches
300
- if (fetchingRef.current) {
301
- return;
302
- }
303
-
304
- // Clamp to available date range
305
- if (!visibleRange) return;
306
-
307
- const clampedStart = isBefore(visibleRange.start, EARLIEST_AVAILABILITY_DATE)
308
- ? EARLIEST_AVAILABILITY_DATE
309
- : visibleRange.start;
310
- const endOfLatestDay = endOfDay(LATEST_AVAILABILITY_DATE);
311
- let clampedEnd = isAfter(visibleRange.end, endOfLatestDay)
312
- ? endOfLatestDay
313
- : visibleRange.end;
314
-
315
- // Ensure we include the selected date if it's after the visible range end
316
- if (selectedDate) {
317
- try {
318
- const selectedDateObj = parseISO(selectedDate);
319
- if (isAfter(selectedDateObj, clampedEnd)) {
320
- clampedEnd = selectedDateObj;
321
- }
322
- } catch {
323
- // Ignore parse errors
324
- }
325
- }
326
-
327
- // Check if we need to fetch this range
328
- if (!needsFetch(clampedStart, clampedEnd)) {
329
- setLoadingAvailabilities(false);
330
- return;
331
- }
332
-
333
- fetchingRef.current = true;
334
- setLoadingAvailabilities(true);
335
-
336
- try {
337
- const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
338
- const endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
339
-
340
- // Date-first flow: fetch all options at once (productId + allOptions=true)
341
- const result = await getAvailabilities(product.productId, startDate, endDateStr, {
342
- allOptions: true,
343
- promoCode: appliedPromoCode || undefined,
344
- });
345
- const allFetchedAvailabilities = result.availabilities;
346
-
347
- if (result.pricingConfig && !pricingConfigSetRef.current) {
348
- setPricingConfig(result.pricingConfig);
349
- pricingConfigSetRef.current = true;
350
- }
351
- if (result.precomputedPrices) {
352
- setPrecomputedPrices(result.precomputedPrices);
353
- }
354
- if (result.resourcePriceByCurrency) {
355
- setResourcePriceByCurrency(result.resourcePriceByCurrency);
356
- }
357
- if (result.resourcePriceByOption) {
358
- setResourcePriceByOption(result.resourcePriceByOption);
359
- } else {
360
- setResourcePriceByOption(null);
361
- }
362
-
363
- // Merge: key by dateTime:productId so we keep all options per date
364
- setAvailabilities(prev => {
365
- const existingMap = new Map(prev.map(avail => [`${avail.dateTime}:${avail.productId || avail.productOptionId || avail.availabilityId}`, avail]));
366
- allFetchedAvailabilities.forEach(avail => {
367
- const key = `${avail.dateTime}:${avail.productId || avail.productOptionId || avail.availabilityId}`;
368
- existingMap.set(key, avail);
369
- });
370
- return Array.from(existingMap.values());
371
- });
372
-
373
- // Mark this range as fetched
374
- fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
375
- // Sort and merge overlapping ranges
376
- fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
377
- const merged: Array<{ start: Date; end: Date }> = [];
378
- for (const r of fetchedRangesRef.current) {
379
- if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
380
- merged.push({ start: r.start, end: r.end });
381
- } else {
382
- merged[merged.length - 1].end = r.end > merged[merged.length - 1].end
383
- ? r.end
384
- : merged[merged.length - 1].end;
385
- }
386
- }
387
- fetchedRangesRef.current = merged;
388
-
389
- // Initialize passenger count to 1 (only if not already set)
390
- if (passengerCount === 0) {
391
- setPassengerCount(1);
392
- }
393
- } catch (err) {
394
- setError(err instanceof Error ? err.message : 'Failed to load availabilities');
395
- } finally {
396
- setLoadingAvailabilities(false);
397
- fetchingRef.current = false;
398
- }
399
- }
400
-
401
- fetchAvailabilities();
402
- }, [visibleRange, activeOptions, selectedDate, refreshKey, product.productId, appliedPromoCode]);
403
-
404
- // Refetch availabilities when user returns to this tab (e.g. after reconciling capacity on dashboard)
405
- useEffect(() => {
406
- const handleVisibilityChange = () => {
407
- if (document.visibilityState === 'visible') {
408
- fetchedRangesRef.current = [];
409
- setRefreshKey((k) => k + 1);
410
- }
411
- };
412
- document.addEventListener('visibilitychange', handleVisibilityChange);
413
- return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
414
- }, []);
415
-
416
- // Memoized callback for visible range changes
417
- // Only update if the range actually changed to avoid unnecessary fetches
418
- const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
419
- const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
420
- const lastRange = lastVisibleRangeRef.current;
421
- // Update if this is the first range or if it changed significantly (more than a day)
422
- const rangeChanged = !lastRange ||
423
- Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
424
- Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
425
-
426
- if (rangeChanged) {
427
- lastVisibleRangeRef.current = { start, end };
428
- // Always update state to trigger fetch, even if needsFetch might return false
429
- // The needsFetch check will prevent unnecessary API calls, but we want the state update
430
- setVisibleRange({ start, end });
431
- }
432
- }, []);
433
-
434
- // Group availabilities by date (date-first: each date can have multiple options)
435
- const availabilitiesByDate = useMemo(() => {
436
- return availabilities.reduce((acc, avail) => {
437
- const dateStr = avail.dateTime.split('T')[0];
438
- if (!acc[dateStr]) acc[dateStr] = [];
439
- acc[dateStr].push(avail);
440
- return acc;
441
- }, {} as Record<string, Availability[]>);
442
- }, [availabilities]);
443
-
444
- // Options available for the selected date (for option selector - only show options that have availability that day)
445
- const optionsAvailableForSelectedDate = useMemo(() => {
446
- if (!selectedDate) return [];
447
- const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
448
- const optionIds = new Set(dateAvailabilities.map(a => a.productId || a.productOptionId).filter(Boolean));
449
- return activeOptions.filter(opt => optionIds.has(opt.optionId));
450
- }, [selectedDate, availabilitiesByDate, activeOptions]);
451
-
452
- const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
453
-
454
- // Find the earliest availability date - memoize with a stable reference
455
- // Only recalculate if we don't have a cached value or if the new earliest is actually earlier
456
- // IMPORTANT: Never return null once we have a value, to prevent calendar reset during loading
457
- const earliestAvailabilityDateRef = useRef<Date | null>(null);
458
- const earliestAvailabilityDate = useMemo(() => {
459
- if (dates.length === 0) {
460
- // If we have a cached value, keep using it even during loading
461
- return earliestAvailabilityDateRef.current || EARLIEST_AVAILABILITY_DATE;
462
- }
463
- const firstDate = dates[0];
464
- const [year, month, day] = firstDate.split('-').map(Number);
465
- const newEarliest = new Date(year, month - 1, day);
466
-
467
- // Only update if we don't have a cached value or if the new one is earlier
468
- if (!earliestAvailabilityDateRef.current || newEarliest < earliestAvailabilityDateRef.current) {
469
- earliestAvailabilityDateRef.current = newEarliest;
470
- }
471
-
472
- return earliestAvailabilityDateRef.current;
473
- }, [dates]);
474
-
475
- // Get selected availability's suggested start times
476
- const suggestedStartTimes = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
477
-
478
- // Resource capacity per shuttle (hardcoded for now - should come from backend/config)
479
- const RESOURCE_CAPACITY = 13;
480
-
481
- // Calculate number of resources needed based on passenger count
482
- const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
483
-
484
- // Resource price: prefer selected availability's rates (includes dynamic pricing) when available;
485
- // otherwise fall back to resourcePriceByOption, resourcePriceByCurrency, or precomputedPrices
486
- const resourcePrice = useMemo(() => {
487
- // When user has selected a date/availability, use the per-slot price from rates (has dynamic pricing applied)
488
- if (selectedAvailability?.rates) {
489
- const resourceRate = selectedAvailability.rates.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
490
- if (resourceRate) {
491
- const fromRate = resourceRate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? resourceRate.price : undefined);
492
- if (fromRate != null) return fromRate;
493
- }
494
- }
495
- if (selectedOption && resourcePriceByOption?.[selectedOption]?.[currency] != null) {
496
- return resourcePriceByOption[selectedOption][currency];
497
- }
498
- const fromApi = resourcePriceByCurrency?.[currency];
499
- if (fromApi != null) return fromApi;
500
- const fromPrecomputed = precomputedPrices?.['RESOURCE']?.[currency];
501
- if (fromPrecomputed != null) return fromPrecomputed;
502
- const fromProduct = selectedOptionConfig?.pricing?.['RESOURCE'];
503
- if (fromProduct != null) return fromProduct;
504
- return 0;
505
- }, [selectedAvailability, resourcePriceByOption, resourcePriceByCurrency, precomputedPrices, selectedOption, selectedOptionConfig, currency]);
506
-
507
- // Calculate base price: resourcePrice * resourceCount
508
- const basePrice = resourcePrice * resourceCount;
509
-
510
- // Tax: for CAD/USD tax is separate; for EUR/GBP/AUD it's included in price
511
- const isTaxIncludedInPrice = (pricingConfig?.currenciesWithTaxIncluded ?? []).includes(currency);
512
- const taxRate = pricingConfig?.taxRate ?? 0.05;
513
- // Cancellation policy fee (from selected policy)
514
- const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
515
- const cancellationPolicyFee = selectedCancellationPolicy ? (selectedCancellationPolicy.feeByCurrency[currency] ?? 0) : 0;
516
- // Add-on totals (lunch, animals)
517
- const addOnTotal = useMemo(() => {
518
- let sum = 0;
519
- for (const sel of addOnSelections) {
520
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
521
- if (!addOn) continue;
522
- const basePrice = addOn.price ?? 0;
523
- const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
524
- const variantAdjustment = hasVariant
525
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
526
- : 0;
527
- sum += (basePrice + variantAdjustment) * (sel.quantity ?? 1);
528
- }
529
- return sum;
530
- }, [addOnSelections, addOns]);
531
-
532
- // Additional hours amount (admin only: $170/hour)
533
- const additionalHoursAmount = (isAdmin ? additionalHoursCount : 0) * ADDITIONAL_HOUR_PRICE;
534
- // Subtotal = base + add-ons + additional hours (needed for promo discount effect)
535
- const subtotal = basePrice + addOnTotal + additionalHoursAmount;
536
-
537
- // Promo discount from backend (admin only; order-level)
538
- const hasOngoingDiscount = useMemo(
539
- () =>
540
- selectedAvailability?.rates?.some((r) =>
541
- (r.appliedAdjustments ?? r.applied_adjustments ?? []).some((a) => (a.type ?? '').toLowerCase() === 'deal')
542
- ) ?? false,
543
- [selectedAvailability]
544
- );
545
-
546
- useEffect(() => {
547
- if (!appliedPromoCode || !selectedOption || !selectedDate || !selectedStartTime || resourceCount === 0) {
548
- setPromoDiscountAmount(0);
549
- setIsGiftCard(false);
550
- setIsVoucher(false);
551
- return;
552
- }
553
- const companyId = product.companyId;
554
- if (!companyId) {
555
- setPromoDiscountAmount(0);
556
- return;
557
- }
558
- const items = [{ category: 'RESOURCE', qty: resourceCount }];
559
- const dateTime = selectedAvailability?.dateTime ?? `${selectedDate}T${selectedStartTime}:00`;
560
- let cancelled = false;
561
- getPromoDiscount(
562
- appliedPromoCode,
563
- companyId,
564
- product.productId,
565
- selectedOption,
566
- currency,
567
- items,
568
- dateTime,
569
- subtotal
570
- )
571
- .then((res) => {
572
- if (!cancelled) {
573
- setPromoDiscountAmount(res.discount ?? 0);
574
- setIsGiftCard(res.isGiftCard ?? false);
575
- setIsVoucher(res.isVoucher ?? false);
576
- }
577
- })
578
- .catch(() => {
579
- if (!cancelled) {
580
- setPromoDiscountAmount(0);
581
- setIsGiftCard(false);
582
- setIsVoucher(false);
583
- }
584
- });
585
- return () => { cancelled = true; };
586
- }, [appliedPromoCode, selectedOption, selectedDate, selectedStartTime, selectedAvailability?.dateTime, resourceCount, subtotal, product.companyId, product.productId, currency]);
587
-
588
- // Percentage/fixed promos: tax on discounted amount (promo before GST). Vouchers/gift cards: tax on base (original).
589
- const taxAmount =
590
- isTaxIncludedInPrice
591
- ? 0
592
- : Math.round(
593
- (promoDiscountAmount > 0 && !isGiftCard && !isVoucher
594
- ? (subtotal - promoDiscountAmount) * taxRate
595
- : basePrice * taxRate
596
- ) * 100
597
- ) / 100;
598
-
599
- // Total = subtotal + tax (when not included) + cancellation fee - promo discount
600
- const totalPrice = subtotal + taxAmount + cancellationPolicyFee - promoDiscountAmount;
601
-
602
- // Calculate duration: base + admin add-on hours (each additional hour = 60 min)
603
- const calculatedDuration = (privateShuttleConfig?.baseDurationMinutes ?? 0) + (isAdmin ? additionalHoursCount * 60 : 0);
604
-
605
- // Calculate end time
606
- const calculatedEndTime = useMemo(() => {
607
- if (!selectedStartTime || calculatedDuration === 0) return null;
608
-
609
- try {
610
- // Parse selected date and start time
611
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
612
- const dateTime = new Date(selectedDate);
613
- dateTime.setHours(hours, minutes, 0, 0);
614
-
615
- // Add duration
616
- const endDateTime = new Date(dateTime.getTime() + calculatedDuration * 60 * 1000);
617
-
618
- return formatInTimeZone(endDateTime, companyTimezone, 'h:mm a');
619
- } catch {
620
- return null;
621
- }
622
- }, [selectedStartTime, selectedDate, calculatedDuration, companyTimezone]);
623
-
624
- // Calculate deposit (based on totalPrice which includes tax when applicable)
625
- const depositInfo = useMemo(() => {
626
- if (!privateShuttleConfig?.depositConfig || totalPrice === 0) return null;
627
-
628
- const { depositConfig } = privateShuttleConfig;
629
- const percentageDeposit = depositConfig.percentage ? totalPrice * depositConfig.percentage : 0;
630
- const fixedDeposit = depositConfig.fixedAmount || 0;
631
-
632
- const depositAmount = Math.round(Math.max(percentageDeposit, fixedDeposit) * 100) / 100;
633
- const balanceAmount = Math.round((totalPrice - depositAmount) * 100) / 100;
634
-
635
- return {
636
- depositAmount,
637
- balanceAmount,
638
- totalPrice,
639
- };
640
- }, [privateShuttleConfig, totalPrice]);
641
-
642
- // Build itinerary display items for sticky "Build Your Itinerary" section
643
- const itineraryDisplayItems = useMemo(() => {
644
- const items: Array<{ time?: string; label?: string; prefix?: string; clickableLabel?: string; timesNote?: string; isProposedStops?: boolean }> = [];
645
- const pickupTime = selectedStartTime
646
- ? (() => {
647
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
648
- const d = new Date();
649
- d.setHours(hours, minutes, 0, 0);
650
- return format(d, 'h:mm a');
651
- })()
652
- : 'TBD';
653
- const dropoffTime = calculatedEndTime ?? 'TBD';
654
- const pickupDropoffLabel = customPickupAddress
655
- ?? selectedPickupLocation?.name
656
- ?? (pickupLocationSkipped ? (t('booking.pickupAtTourStartLocation') || 'tour start location') : null)
657
- ?? (t('booking.yourPickupLocation') || 'your pickup location');
658
- items.push({
659
- time: pickupTime,
660
- prefix: `${t('booking.pickupAt') || 'Pickup at'} `,
661
- clickableLabel: pickupDropoffLabel,
662
- });
663
- if (selectedOption && product.itineraryBuilder && draftItineraryDestinations.length > 0) {
664
- const destMap = new Map(product.itineraryBuilder.destinations.map((d) => [d.id, d.label]));
665
- const locationLabels = draftItineraryDestinations.map((id) => destMap.get(id) || id.replace(/_/g, ' '));
666
- items.push({
667
- label: locationLabels.join(', '),
668
- timesNote: t('booking.orderAndTimesToBeConfirmed') || '(order and times to be confirmed by our team)',
669
- isProposedStops: true,
670
- });
671
- }
672
- items.push({
673
- time: dropoffTime,
674
- prefix: t('booking.dropOffAtPrefix') || 'Drop off at ',
675
- clickableLabel: pickupDropoffLabel,
676
- });
677
- return items;
678
- }, [selectedOption, product.itineraryBuilder, draftItineraryDestinations, selectedStartTime, calculatedEndTime, customPickupAddress, selectedPickupLocation, pickupLocationSkipped, t]);
679
-
680
- // Scroll to pickup location section when "your pickup location" is clicked in itinerary
681
- const handlePickupLocationClick = useCallback(() => {
682
- const pickupSection = document.getElementById('pickup-location-section');
683
- const itineraryBox = document.querySelector('[class*="sticky top-"]');
684
- if (pickupSection && itineraryBox) {
685
- const headerHeight = window.innerWidth >= 640 ? 73 : 136;
686
- const itineraryHeight = itineraryBox.getBoundingClientRect().height;
687
- const elementPosition = pickupSection.getBoundingClientRect().top;
688
- const offsetPosition = elementPosition + window.pageYOffset - headerHeight - itineraryHeight - 16;
689
- window.scrollTo({
690
- top: Math.max(0, offsetPosition),
691
- behavior: 'smooth',
692
- });
693
- } else if (pickupSection) {
694
- pickupSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
695
- }
696
- }, []);
697
-
698
- // Get RESOURCE price for an option (for Select Tour Option grid)
699
- // When selectedDate is set, prefer the per-slot price from availability (includes dynamic pricing)
700
- const getOptionPrice = useCallback((optionId: string): number => {
701
- if (selectedDate) {
702
- const dateAvail = availabilitiesByDate[selectedDate]?.find(
703
- (a) => (a.productId || a.productOptionId) === optionId
704
- );
705
- const resourceRate = dateAvail?.rates?.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
706
- if (resourceRate) {
707
- const fromRate = resourceRate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? resourceRate.price : undefined);
708
- if (fromRate != null) return fromRate;
709
- }
710
- }
711
- if (resourcePriceByOption?.[optionId]?.[currency] != null) return resourcePriceByOption[optionId][currency];
712
- const fromApi = resourcePriceByCurrency?.[currency];
713
- if (fromApi != null) return fromApi;
714
- const opt = activeOptions.find((o) => o.optionId === optionId);
715
- return opt?.pricing?.['RESOURCE'] ?? 0;
716
- }, [selectedDate, availabilitiesByDate, resourcePriceByOption, resourcePriceByCurrency, currency, activeOptions]);
717
-
718
- const handleDateSelect = (date: string) => {
719
- setSelectedDate(date);
720
- setSelectedOption('');
721
- setSelectedAvailability(null);
722
- setSelectedStartTime('');
723
- setIsCustomTimeMode(false);
724
- setDraftItineraryDestinations([]);
725
- setDraftItineraryPlanningNotes('');
726
- setError('');
727
- };
728
-
729
- // Auto-select option when date selected: most popular if set and available, otherwise first available (or only one)
730
- useEffect(() => {
731
- if (selectedDate && !selectedOption && optionsAvailableForSelectedDate.length > 0) {
732
- const mostPopularOpt = optionsAvailableForSelectedDate.find(o => o.mostPopular);
733
- const opt = mostPopularOpt ?? optionsAvailableForSelectedDate[0];
734
- setSelectedOption(opt.optionId);
735
- const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
736
- const avail = dateAvailabilities.find(a => (a.productId || a.productOptionId) === opt.optionId);
737
- setSelectedAvailability(avail || null);
738
- }
739
- }, [selectedDate, selectedOption, optionsAvailableForSelectedDate, availabilitiesByDate]);
740
-
741
- // Clamp passenger count when availability changes (e.g. fewer vacancies for new date/option)
742
- useEffect(() => {
743
- if (selectedAvailability) {
744
- const maxAvailable = selectedAvailability.vacancies || 0;
745
- if (maxAvailable > 0 && passengerCount > maxAvailable) {
746
- setPassengerCount(maxAvailable);
747
- }
748
- }
749
- }, [selectedAvailability, passengerCount]);
750
-
751
- // Initialize draft itinerary with option's default destinations when option changes
752
- useEffect(() => {
753
- if (selectedOption) {
754
- const optionConfig = activeOptions.find((o) => o.optionId === selectedOption);
755
- const blacklist = optionConfig?.privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist ?? [];
756
- const defaults = optionConfig?.privateShuttleConfig?.itineraryBuilderConfig?.defaultDestinations ?? [];
757
- const initialDestinations = defaults.filter((id) => !blacklist.includes(id));
758
- setDraftItineraryDestinations(initialDestinations);
759
- setDraftItineraryPlanningNotes('');
760
- }
761
- }, [selectedOption, activeOptions]);
762
-
763
- // Auto-select first pickup time when suggested times appear
764
- useEffect(() => {
765
- if (
766
- suggestedStartTimes.length > 0 &&
767
- !isCustomTimeMode &&
768
- !selectedStartTime
769
- ) {
770
- setSelectedStartTime(suggestedStartTimes[0]);
771
- }
772
- }, [suggestedStartTimes, isCustomTimeMode, selectedStartTime]);
773
-
774
- // Auto-select first cancellation policy when policies load
775
- useEffect(() => {
776
- if (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0 && !cancellationPolicyId) {
777
- setCancellationPolicyId(pricingConfig.cancellationPolicies[0].id);
778
- }
779
- }, [pricingConfig?.cancellationPolicies, cancellationPolicyId]);
780
-
781
- const handleOptionSelect = (optionId: string) => {
782
- setSelectedOption(optionId);
783
- setError('');
784
- // Draft itinerary is initialized by useEffect when selectedOption changes
785
- // Set selectedAvailability from the availability for this date + option
786
- if (selectedDate) {
787
- const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
788
- const avail = dateAvailabilities.find(a => (a.productId || a.productOptionId) === optionId);
789
- setSelectedAvailability(avail || null);
790
- } else {
791
- setSelectedAvailability(null);
792
- }
793
- setSelectedStartTime('');
794
- setIsCustomTimeMode(false);
795
- };
796
-
797
- const handleStartTimeSelect = (time: string) => {
798
- setSelectedStartTime(time);
799
- setIsCustomTimeMode(false);
800
- setError('');
801
- };
802
-
803
- const handleCustomTimeRequest = () => {
804
- setIsCustomTimeMode(true);
805
- setSelectedStartTime('');
806
- setError('');
807
- };
808
-
809
- const handleCustomTimeChange = (value: string) => {
810
- setSelectedStartTime(value);
811
- setError('');
812
- };
813
-
814
- const handlePassengerCountChange = (count: number) => {
815
- const maxAvailable = selectedAvailability?.vacancies || 0;
816
- const newCount = Math.max(1, Math.min(maxAvailable, count));
817
- setPassengerCount(newCount);
818
- setError('');
819
- };
820
-
821
- const handleApplyPromo = useCallback(async () => {
822
- const code = promoCodeInput.trim().toUpperCase();
823
- if (!code) return;
824
- if (appliedPromoCode === code) return;
825
- const companyId = product.companyId;
826
- if (!companyId) return;
827
- lastValidatedInputRef.current = code;
828
- setPromoCodeError('');
829
- setPromoCodeValidating(true);
830
- try {
831
- const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
832
- if (result.valid) {
833
- setAppliedPromoCode(code);
834
- fetchedRangesRef.current = [];
835
- if (result.forcedCancellationPolicyId) {
836
- setCancellationPolicyId(result.forcedCancellationPolicyId);
837
- setForcedCancellationPolicyFromPromo(
838
- result.forcedCancellationPolicyLabel
839
- ? { id: result.forcedCancellationPolicyId, label: result.forcedCancellationPolicyLabel }
840
- : { id: result.forcedCancellationPolicyId, label: result.forcedCancellationPolicyId }
841
- );
842
- } else {
843
- setForcedCancellationPolicyFromPromo(null);
844
- }
845
- } else {
846
- const errorMsg =
847
- result.error === 'Promo codes cannot be stacked with deals'
848
- ? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
849
- : (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
850
- setPromoCodeError(errorMsg);
851
- }
852
- } catch (err) {
853
- setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
854
- } finally {
855
- setPromoCodeValidating(false);
856
- }
857
- }, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t]);
858
-
859
- useEffect(() => {
860
- if (!appliedPromoCode || !hasOngoingDiscount) return;
861
- let cancelled = false;
862
- validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, true).then((result) => {
863
- if (cancelled) return;
864
- if (!result.valid && result.error === 'Promo codes cannot be stacked with deals') {
865
- setAppliedPromoCode(null);
866
- setPromoCodeInput(appliedPromoCode);
867
- setPromoCodeError(t('booking.promoCodesCannotStackWithDiscounts') || result.error);
868
- setForcedCancellationPolicyFromPromo(null);
869
- fetchedRangesRef.current = [];
870
- }
871
- });
872
- return () => { cancelled = true; };
873
- }, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t]);
874
-
875
- const handleApplyPromoRef = useRef(handleApplyPromo);
876
- handleApplyPromoRef.current = handleApplyPromo;
877
-
878
- useEffect(() => {
879
- const trimmed = promoCodeInput.trim().toUpperCase();
880
- if (!trimmed) return;
881
- if (appliedPromoCode === trimmed) return;
882
- if (promoCodeValidating) return;
883
- if (lastValidatedInputRef.current === trimmed) return;
884
- const timer = setTimeout(() => {
885
- handleApplyPromoRef.current();
886
- }, 600);
887
- return () => clearTimeout(timer);
888
- }, [promoCodeInput, appliedPromoCode, promoCodeValidating]);
889
-
890
- const handleCheckout = async () => {
891
- if (!selectedDate || !selectedStartTime || passengerCount < 1) {
892
- setError('Please select a date, start time, and enter passenger count');
893
- return;
894
- }
895
-
896
- // Require pickup location choice (selected location, custom address, OR "I don't know") — same as BookingFlow
897
- if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !customPickupAddress && !pickupLocationSkipped) {
898
- setError(t('booking.selectPickupLocation'));
899
- return;
900
- }
901
-
902
- // Validate email (required) - skip in change mode
903
- if (!isChangeMode) {
904
- if (!email) {
905
- setError(t('booking.enterEmail') || 'Please enter your email address');
906
- return;
907
- }
908
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
909
- setError(t('booking.invalidEmail') || 'Please enter a valid email address');
910
- return;
911
- }
912
- if (!lastName?.trim()) {
913
- setError(t('booking.enterLastName') || 'Please enter your last name');
914
- return;
915
- }
916
- }
917
-
918
- setLoading(true);
919
- setError('');
920
-
921
- try {
922
- // For Private Shuttle, bookingItems should be [{ category: "RESOURCE", count: resourceCount }]
923
- const bookingItems = [{ category: 'RESOURCE', count: resourceCount }];
924
-
925
- if (!selectedOption) {
926
- setError('No product option selected');
927
- setLoading(false);
928
- return;
929
- }
930
-
931
- // Change mode: call onChangeBooking instead of createReservation
932
- if (isChangeMode && onChangeBooking) {
933
- const selectedPickupLocation = pickupLocationId
934
- ? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
935
- : null;
936
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
937
- const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
938
- await onChangeBooking({
939
- productId: selectedOption,
940
- dateTime: selectedDate,
941
- bookingItems,
942
- returnAvailabilityId: null,
943
- pickupLocationId: pickupLocationId ?? null,
944
- travelerHotel: selectedPickupLocation?.name ?? customPickupAddress ?? initialBooking?.travelerHotel ?? null,
945
- startTime: startTimeISO,
946
- passengerCount,
947
- childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : null,
948
- foodRestrictions: foodRestrictions.trim() || null,
949
- addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
950
- cancellationPolicyId: cancellationPolicyId ?? initialBooking?.cancellationPolicyId ?? null,
951
- promoCode: appliedPromoCode ?? null,
952
- newTotalAmount: keepOriginalPrice ? undefined : totalPrice,
953
- keepOriginalPrice: keepOriginalPrice || undefined,
954
- additionalHoursCount: isAdmin ? additionalHoursCount : null,
955
- });
956
- setLoading(false);
957
- return;
958
- }
959
-
960
- // Build start time as ISO 8601 string in company timezone
961
- // Simple approach: construct ISO string directly with timezone offset
962
- // America/Edmonton is UTC-6 (MST) or UTC-7 (MDT), but we'll let the backend handle validation
963
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
964
- const hoursStr = String(hours).padStart(2, '0');
965
- const minutesStr = String(minutes).padStart(2, '0');
966
-
967
- // Construct ISO 8601 string: YYYY-MM-DDTHH:mm:ss with timezone offset
968
- // We'll use UTC-6 (MST) as the offset for America/Edmonton
969
- // Format: 2026-06-01T06:00:00-06:00
970
- const startTimeISO = `${selectedDate}T${hoursStr}:${minutesStr}:00-06:00`;
971
-
972
- // Get the hotel name if a pickup location was selected
973
- const selectedPickupLocation = pickupLocationId
974
- ? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
975
- : null;
976
-
977
- const reservation = await createReservation({
978
- productId: selectedOption,
979
- dateTime: selectedDate, // Date-only for Private Shuttle
980
- bookingItems,
981
- currency: currency,
982
- startTime: startTimeISO,
983
- passengerCount: passengerCount,
984
- pickupLocationId: pickupLocationId || undefined,
985
- cancellationPolicyId: cancellationPolicyId || undefined,
986
- promoCode: isAdmin && appliedPromoCode ? appliedPromoCode : undefined,
987
- // Pass hotel name when pickup location is selected (for reference)
988
- // Don't set travelerHotel when user selects "I don't know" - leave it undefined
989
- // This allows us to distinguish between "unknown" (null) and "unmapped hotel name" (not null)
990
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
991
- // Draft itinerary (destinations, planning notes only)
992
- draftItinerary:
993
- draftItineraryDestinations.length > 0 || draftItineraryPlanningNotes.trim()
994
- ? {
995
- destinations: draftItineraryDestinations,
996
- planningNotes: draftItineraryPlanningNotes.trim() || undefined,
997
- }
998
- : undefined,
999
- childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : undefined,
1000
- foodRestrictions: foodRestrictions.trim() || undefined,
1001
- addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
1002
- additionalHoursCount: isAdmin && additionalHoursCount > 0 ? additionalHoursCount : undefined,
1003
- });
1004
-
1005
- if (!reservation || !reservation.reservationReference) {
1006
- throw new Error('Invalid reservation response: missing reservationReference');
1007
- }
1008
-
1009
- onSuccess?.({
1010
- reservationReference: reservation.reservationReference,
1011
- });
1012
-
1013
- const amountToPay = depositInfo?.depositAmount ?? totalPrice;
1014
- // Always send full line items (Shuttle, GST, etc.) so receipt shows full breakdown.
1015
- // For deposit: we charge amountToPay but receipt stores full total + line items.
1016
- const lines = [
1017
- {
1018
- label: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
1019
- amount: basePrice,
1020
- type: 'TICKET' as const,
1021
- quantity: resourceCount,
1022
- },
1023
- ...addOnSelections.map((sel) => {
1024
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1025
- if (!addOn) return null;
1026
- const base = addOn.price ?? 0;
1027
- const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
1028
- const adj = hasVariant
1029
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1030
- : 0;
1031
- const amt = (base + adj) * (sel.quantity ?? 1);
1032
- const variantLabel = hasVariant
1033
- ? addOn.variants?.find((v) => v.id === sel.variantId)?.label
1034
- : null;
1035
- const qty = sel.quantity ?? 1;
1036
- return { label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name, amount: amt, type: 'FEE' as const };
1037
- }).filter(Boolean) as Array<{ label: string; amount: number; type: 'FEE' }>,
1038
- ...(additionalHoursAmount > 0
1039
- ? [{
1040
- label: additionalHoursCount === 1 ? 'Additional hour' : `Additional hours (${additionalHoursCount})`,
1041
- amount: additionalHoursAmount,
1042
- type: 'ADDITIONAL_HOURS' as const,
1043
- }]
1044
- : []),
1045
- ...(cancellationPolicyFee > 0 && selectedCancellationPolicy
1046
- ? [{
1047
- label: selectedCancellationPolicy.label,
1048
- amount: cancellationPolicyFee,
1049
- type: 'CANCELLATION_UPGRADE' as const,
1050
- }]
1051
- : []),
1052
- ...(taxAmount > 0
1053
- ? [
1054
- {
1055
- label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
1056
- amount: taxAmount,
1057
- type: 'TAX' as const,
1058
- },
1059
- ]
1060
- : []),
1061
- ];
1062
- const receiptTotal = depositInfo ? depositInfo.totalPrice : totalPrice;
1063
- const linesWithPromo = [
1064
- ...lines,
1065
- ...(promoDiscountAmount > 0
1066
- ? [{
1067
- label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (t('booking.discount') || 'Discount'),
1068
- amount: -promoDiscountAmount,
1069
- type: isGiftCard ? ('GIFT_CARD' as const) : ('PROMO_CODE' as const),
1070
- }]
1071
- : []),
1072
- ];
1073
- const checkoutBreakdown = buildCheckoutBreakdown({
1074
- lines: linesWithPromo,
1075
- totalAmount: receiptTotal,
1076
- currency,
1077
- roundingLabel: t('booking.rounding') || 'Rounding',
1078
- });
1079
-
1080
- // Build itineraryDisplay for storage (same for paid and confirm-without-payment flows)
1081
- const itineraryDisplayForStorage: ItineraryDisplayStep[] = itineraryDisplayItems.map((item, i) => {
1082
- if (item.isProposedStops) {
1083
- return { stepType: ItineraryStepType.draft, time: 'Proposed stops', place: item.label ?? undefined };
1084
- }
1085
- const isPickup = i === 0;
1086
- return {
1087
- stepType: isPickup ? ItineraryStepType.pickup : ItineraryStepType.drop_off,
1088
- time: item.time ?? 'TBD',
1089
- place: item.clickableLabel ?? undefined,
1090
- };
1091
- });
1092
-
1093
- // Admin + deposit: show choice to pay or confirm without payment
1094
- if (isAdmin && depositInfo) {
1095
- setAdminChoiceData({
1096
- reservationReference: reservation.reservationReference,
1097
- checkoutBreakdown,
1098
- depositAmount: depositInfo.depositAmount,
1099
- balanceAmount: depositInfo.balanceAmount,
1100
- totalAmount: depositInfo.totalPrice,
1101
- balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
1102
- itineraryDisplay: itineraryDisplayForStorage,
1103
- pickupLocationId: pickupLocationId ?? undefined,
1104
- });
1105
- setShowAdminPaymentChoice(true);
1106
- setLoading(false);
1107
- return;
1108
- }
1109
-
1110
- const paymentIntent = await createPaymentIntent({
1111
- customerFirstName: firstName.trim() || undefined,
1112
- customerLastName: lastName.trim() || undefined,
1113
- productId: product.productId,
1114
- optionId: selectedOption,
1115
- date: selectedDate,
1116
- time: selectedStartTime,
1117
- quantity: resourceCount,
1118
- customerEmail: email,
1119
- currency: currency,
1120
- reservationReference: reservation.reservationReference,
1121
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1122
- pickupLocationId: pickupLocationId ?? undefined,
1123
- itineraryDisplay: itineraryDisplayForStorage,
1124
- termsAcceptedAt: termsAcceptedAt ?? undefined,
1125
- cancellationPolicyId: cancellationPolicyId || undefined,
1126
- promoCode: isAdmin && appliedPromoCode ? appliedPromoCode : undefined,
1127
- checkoutBreakdown,
1128
- ...(depositInfo && {
1129
- paymentPlanType: 'DEPOSIT' as const,
1130
- depositAmount: depositInfo.depositAmount,
1131
- balanceAmount: depositInfo.balanceAmount,
1132
- totalAmount: depositInfo.totalPrice,
1133
- balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
1134
- }),
1135
- skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
1136
- disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
1137
- });
1138
-
1139
- // Free booking (e.g. voucher covers full total): confirm without payment, then redirect to success
1140
- if (paymentIntent.freeBooking) {
1141
- const freeBookingResult = await confirmFreeBooking({
1142
- reservationReference: reservation.reservationReference,
1143
- productId: product.productId,
1144
- optionId: selectedOption,
1145
- date: selectedDate,
1146
- time: selectedStartTime,
1147
- customerEmail: email || undefined,
1148
- customerFirstName: firstName.trim() || undefined,
1149
- customerLastName: lastName.trim() || undefined,
1150
- currency: currency,
1151
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1152
- pickupLocationId: pickupLocationId || undefined,
1153
- itineraryDisplay: itineraryDisplayForStorage,
1154
- termsAcceptedAt: termsAcceptedAt ?? undefined,
1155
- skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
1156
- disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
1157
- });
1158
- // Show manage UI: in provider-dashboard use callback (e.g. dialog); otherwise redirect to /manage
1159
- const ref = formatBookingRefForDisplay(freeBookingResult.bookingReference);
1160
- const ln = lastName.trim();
1161
- if (onShowManage) {
1162
- onShowManage({ ref, lastName: ln });
1163
- } else {
1164
- const params = new URLSearchParams({ ref, lastName: ln });
1165
- window.location.href = `/manage?${params.toString()}`;
1166
- }
1167
- setLoading(false);
1168
- return;
1169
- }
1170
-
1171
- const ticketLinesForModal: CheckoutModalLineItem[] = [
1172
- {
1173
- line: {
1174
- category: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
1175
- qty: resourceCount,
1176
- pricePerUnit: resourcePrice,
1177
- itemTotal: basePrice,
1178
- },
1179
- breakdown: null,
1180
- },
1181
- ];
1182
- const feeLineItemsForModal: { name: string; totalAmount: number }[] = [
1183
- ...addOnSelections.map((sel) => {
1184
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1185
- if (!addOn) return null;
1186
- const base = addOn.price ?? 0;
1187
- const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
1188
- const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
1189
- const amt = (base + adj) * (sel.quantity ?? 1);
1190
- const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
1191
- const qty = sel.quantity ?? 1;
1192
- const label = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
1193
- return { name: label, totalAmount: amt };
1194
- }).filter(Boolean) as { name: string; totalAmount: number }[],
1195
- ...(additionalHoursAmount > 0
1196
- ? [{ name: additionalHoursCount === 1 ? 'Additional hour' : `${additionalHoursCount} additional hours`, totalAmount: additionalHoursAmount }]
1197
- : []),
1198
- ];
1199
-
1200
- setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
1201
- setCheckoutModalData({
1202
- reservationReference: reservation.reservationReference,
1203
- customerLastName: lastName.trim(),
1204
- ticketLines: ticketLinesForModal,
1205
- feeLineItems: feeLineItemsForModal,
1206
- returnPriceAdjustment: 0,
1207
- subtotal: depositInfo ? totalPrice : subtotal,
1208
- tax: taxAmount,
1209
- total: amountToPay,
1210
- totalQuantity: resourceCount,
1211
- isTaxIncludedInPrice,
1212
- taxRate,
1213
- isDepositPayment: !!depositInfo,
1214
- balanceChargeDaysBefore: depositInfo ? (privateShuttleConfig?.balanceChargeDaysBefore ?? 7) : undefined,
1215
- cancellationPolicyFee: cancellationPolicyFee > 0 ? cancellationPolicyFee : undefined,
1216
- cancellationPolicyLabel: selectedCancellationPolicy?.label,
1217
- promoDiscountAmount: promoDiscountAmount > 0 ? promoDiscountAmount : 0,
1218
- discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
1219
- });
1220
- setShowCheckoutModal(true);
1221
- setLoading(false);
1222
- } catch (err) {
1223
- setError(err instanceof Error ? err.message : 'Something went wrong');
1224
- setLoading(false);
1225
- }
1226
- };
1227
-
1228
- const handleConfirmWithoutPayment = async () => {
1229
- if (!adminChoiceData) return;
1230
- setLoading(true);
1231
- setError('');
1232
- try {
1233
- const result = await confirmBookingWithoutPayment({
1234
- reservationReference: adminChoiceData.reservationReference,
1235
- productId: product.productId,
1236
- optionId: selectedOption,
1237
- date: selectedDate,
1238
- time: selectedStartTime,
1239
- customerEmail: email || undefined,
1240
- customerFirstName: firstName.trim() || undefined,
1241
- customerLastName: lastName.trim() || undefined,
1242
- currency: currency,
1243
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1244
- pickupLocationId: adminChoiceData.pickupLocationId ?? pickupLocationId ?? undefined,
1245
- itineraryDisplay: adminChoiceData.itineraryDisplay,
1246
- termsAcceptedAt: termsAcceptedAt ?? undefined,
1247
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1248
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
1249
- checkoutBreakdown: adminChoiceData.checkoutBreakdown,
1250
- depositAmount: adminChoiceData.depositAmount,
1251
- balanceAmount: adminChoiceData.balanceAmount,
1252
- totalAmount: adminChoiceData.totalAmount,
1253
- balanceChargeDaysBefore: adminChoiceData.balanceChargeDaysBefore,
1254
- });
1255
- const ref = formatBookingRefForDisplay(result.bookingReference);
1256
- const ln = lastName.trim();
1257
- setShowAdminPaymentChoice(false);
1258
- setAdminChoiceData(null);
1259
- if (onShowManage) {
1260
- onShowManage({ ref, lastName: ln });
1261
- } else {
1262
- const params = new URLSearchParams({ ref, lastName: ln });
1263
- window.location.href = `/manage?${params.toString()}`;
1264
- }
1265
- } catch (err) {
1266
- setError(err instanceof Error ? err.message : 'Failed to confirm booking');
1267
- } finally {
1268
- setLoading(false);
1269
- }
1270
- };
1271
-
1272
- const handlePayDepositNow = async () => {
1273
- if (!adminChoiceData) return;
1274
- setLoading(true);
1275
- setError('');
1276
- try {
1277
- const paymentIntent = await createPaymentIntent({
1278
- customerFirstName: firstName.trim() || undefined,
1279
- customerLastName: lastName.trim() || undefined,
1280
- productId: product.productId,
1281
- optionId: selectedOption,
1282
- date: selectedDate,
1283
- time: selectedStartTime,
1284
- quantity: resourceCount,
1285
- customerEmail: email,
1286
- currency: currency,
1287
- reservationReference: adminChoiceData.reservationReference,
1288
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1289
- pickupLocationId: adminChoiceData.pickupLocationId ?? pickupLocationId ?? undefined,
1290
- itineraryDisplay: adminChoiceData.itineraryDisplay,
1291
- termsAcceptedAt: termsAcceptedAt ?? undefined,
1292
- cancellationPolicyId: cancellationPolicyId || undefined,
1293
- promoCode: isAdmin && appliedPromoCode ? appliedPromoCode : undefined,
1294
- checkoutBreakdown: adminChoiceData.checkoutBreakdown,
1295
- paymentPlanType: 'DEPOSIT' as const,
1296
- depositAmount: adminChoiceData.depositAmount,
1297
- balanceAmount: adminChoiceData.balanceAmount,
1298
- totalAmount: adminChoiceData.totalAmount,
1299
- balanceChargeDaysBefore: adminChoiceData.balanceChargeDaysBefore,
1300
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
1301
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
1302
- });
1303
- setShowAdminPaymentChoice(false);
1304
- setAdminChoiceData(null);
1305
- const ticketLinesForModal: CheckoutModalLineItem[] = [
1306
- {
1307
- line: {
1308
- category: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
1309
- qty: resourceCount,
1310
- pricePerUnit: resourcePrice,
1311
- itemTotal: basePrice,
1312
- },
1313
- breakdown: null,
1314
- },
1315
- ];
1316
- const feeLineItemsForPayDeposit: { name: string; totalAmount: number }[] = [
1317
- ...addOnSelections.map((sel) => {
1318
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1319
- if (!addOn) return null;
1320
- const base = addOn.price ?? 0;
1321
- const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
1322
- const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
1323
- const amt = (base + adj) * (sel.quantity ?? 1);
1324
- const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
1325
- const qty = sel.quantity ?? 1;
1326
- const label = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
1327
- return { name: label, totalAmount: amt };
1328
- }).filter(Boolean) as { name: string; totalAmount: number }[],
1329
- ...(additionalHoursAmount > 0
1330
- ? [{ name: additionalHoursCount === 1 ? 'Additional hour' : `${additionalHoursCount} additional hours`, totalAmount: additionalHoursAmount }]
1331
- : []),
1332
- ];
1333
- setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
1334
- setCheckoutModalData({
1335
- reservationReference: adminChoiceData.reservationReference,
1336
- customerLastName: lastName.trim(),
1337
- ticketLines: ticketLinesForModal,
1338
- feeLineItems: feeLineItemsForPayDeposit,
1339
- returnPriceAdjustment: 0,
1340
- subtotal: totalPrice,
1341
- tax: taxAmount,
1342
- total: adminChoiceData.depositAmount,
1343
- totalQuantity: resourceCount,
1344
- isTaxIncludedInPrice,
1345
- taxRate,
1346
- isDepositPayment: true,
1347
- balanceChargeDaysBefore: adminChoiceData.balanceChargeDaysBefore,
1348
- promoDiscountAmount: promoDiscountAmount > 0 ? promoDiscountAmount : 0,
1349
- discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
1350
- });
1351
- setShowCheckoutModal(true);
1352
- } catch (err) {
1353
- setError(err instanceof Error ? err.message : 'Failed to start payment');
1354
- } finally {
1355
- setLoading(false);
1356
- }
1357
- };
1358
-
1359
- if (activeOptions.length === 0) {
1360
- return (
1361
- <div className="flex items-center justify-center py-16">
1362
- <div className="text-red-600">{t('booking.noActiveOption') || 'No active product options available'}</div>
1363
- </div>
1364
- );
1365
- }
1366
-
1367
- return (
1368
- <div className="space-y-8">
1369
- {/* Admin: choose to pay or confirm without payment */}
1370
- {showAdminPaymentChoice && adminChoiceData && (
1371
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
1372
- <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
1373
- <h3 className="text-lg font-semibold text-stone-900 mb-2">Complete booking</h3>
1374
- <p className="text-sm text-stone-600 mb-4">
1375
- Pay the deposit now, or confirm without payment. The customer can pay from the Manage Booking page.
1376
- </p>
1377
- {error && (
1378
- <p className="text-sm text-red-600 mb-4" role="alert">{error}</p>
1379
- )}
1380
- <div className="flex flex-col gap-3">
1381
- <button
1382
- type="button"
1383
- onClick={handlePayDepositNow}
1384
- disabled={loading}
1385
- className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50"
1386
- >
1387
- {loading ? 'Loading...' : `Pay deposit now (${formatCurrencyAmount(adminChoiceData.depositAmount, currency)})`}
1388
- </button>
1389
- <button
1390
- type="button"
1391
- onClick={handleConfirmWithoutPayment}
1392
- disabled={loading}
1393
- className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 disabled:opacity-50"
1394
- >
1395
- Confirm without payment
1396
- </button>
1397
- <button
1398
- type="button"
1399
- onClick={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
1400
- className="w-full py-2 text-sm text-stone-500 hover:text-stone-700"
1401
- >
1402
- Cancel
1403
- </button>
1404
- </div>
1405
- </div>
1406
- </div>
1407
- )}
1408
- {checkoutModalData && (
1409
- <CheckoutModal
1410
- open={showCheckoutModal}
1411
- onClose={() => {
1412
- setShowCheckoutModal(false);
1413
- setCheckoutClientSecret('');
1414
- setCheckoutModalData(null);
1415
- }}
1416
- clientSecret={checkoutClientSecret}
1417
- reservationReference={checkoutModalData.reservationReference}
1418
- customerLastName={checkoutModalData.customerLastName}
1419
- successUrlOverride={getSuccessUrl ? getSuccessUrl({ reservationRef: checkoutModalData.reservationReference, lastName: checkoutModalData.customerLastName ?? '' }) : undefined}
1420
- ticketLines={checkoutModalData.ticketLines}
1421
- feeLineItems={checkoutModalData.feeLineItems}
1422
- returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
1423
- cancellationPolicyFee={checkoutModalData.cancellationPolicyFee}
1424
- cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
1425
- subtotal={checkoutModalData.subtotal}
1426
- tax={checkoutModalData.tax}
1427
- total={checkoutModalData.total}
1428
- totalQuantity={checkoutModalData.totalQuantity}
1429
- isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
1430
- taxRate={checkoutModalData.taxRate}
1431
- currency={currency}
1432
- locale={locale}
1433
- t={t}
1434
- isDepositPayment={checkoutModalData.isDepositPayment}
1435
- balanceChargeDaysBefore={checkoutModalData.balanceChargeDaysBefore}
1436
- promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
1437
- discountLabel={checkoutModalData.discountLabel}
1438
- />
1439
- )}
1440
- {/* Back button */}
1441
- <button
1442
- onClick={onBack}
1443
- className="flex items-center gap-2 text-stone-600 hover:text-stone-900 transition-colors"
1444
- >
1445
- <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1446
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
1447
- </svg>
1448
- <span>{t('products.backToExperiences')}</span>
1449
- </button>
1450
-
1451
- {/* Product header */}
1452
- <div className="bg-gradient-to-r from-emerald-700 to-emerald-600 text-white p-8 rounded-xl">
1453
- <h2 className="text-2xl font-bold mb-2">{product.name}</h2>
1454
- {product.description && (
1455
- <p className="text-emerald-100">{product.description}</p>
1456
- )}
1457
- </div>
1458
-
1459
- {loadingAvailabilities && availabilities.length === 0 ? (
1460
- <div className="flex items-center justify-center py-12">
1461
- <div className="text-center">
1462
- <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600 mb-4"></div>
1463
- <div className="text-stone-600">{t('booking.loadingTimes')}</div>
1464
- </div>
1465
- </div>
1466
- ) : availabilities.length === 0 ? (
1467
- <div className="text-center py-8 text-stone-500">
1468
- {t('booking.noAvailability')}
1469
- </div>
1470
- ) : (
1471
- <>
1472
- {/* Date Selection - first in date-first flow */}
1473
- <div>
1474
- <div className="relative">
1475
- {loadingAvailabilities && (
1476
- <div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
1477
- <div className="text-stone-600">{t('booking.loadingTimes')}</div>
1478
- </div>
1479
- )}
1480
- <Calendar
1481
- availabilitiesByDate={availabilitiesByDate}
1482
- selectedDate={selectedDate}
1483
- onDateSelect={handleDateSelect}
1484
- timezone={companyTimezone}
1485
- earliestDate={earliestAvailabilityDate}
1486
- onVisibleRangeChange={handleVisibleRangeChange}
1487
- currency={currency}
1488
- />
1489
- </div>
1490
- </div>
1491
-
1492
- {/* Build Your Itinerary - sticky section shown after date selection */}
1493
- {selectedDate && (
1494
- <div
1495
- ref={itineraryRef}
1496
- className="sticky top-[136px] sm:top-[73px] z-10 mb-4 p-3 bg-white rounded-lg shadow-sm border border-stone-200"
1497
- >
1498
- <h3 className="font-bold text-stone-900 flex items-center gap-2 mb-2">
1499
- <svg className="flex-shrink-0 w-4 h-4 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1500
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
1501
- </svg>
1502
- {t('booking.buildYourItinerary')}
1503
- </h3>
1504
- {!selectedOption ? (
1505
- <p className="text-sm text-stone-600">
1506
- {t('booking.selectTourOptionToSeeItinerary')}
1507
- </p>
1508
- ) : (
1509
- <div className="space-y-1">
1510
- {itineraryDisplayItems.map((item, index) => (
1511
- <div key={index} className="flex items-center gap-2 text-sm">
1512
- {item.time != null && (
1513
- <span className="font-semibold text-stone-700 tabular-nums shrink-0">{item.time}</span>
1514
- )}
1515
- <span className="text-stone-600">
1516
- {item.isProposedStops ? (
1517
- <>
1518
- <b>{t('booking.proposedStops') || 'Proposed stops'}:</b> {item.label} <em>{item.timesNote}</em>
1519
- </>
1520
- ) : item.clickableLabel ? (
1521
- <>
1522
- {item.prefix}
1523
- <button
1524
- type="button"
1525
- onClick={handlePickupLocationClick}
1526
- className="text-stone-400 hover:text-stone-600 underline cursor-pointer"
1527
- >
1528
- {item.clickableLabel}
1529
- </button>
1530
- </>
1531
- ) : (
1532
- item.label
1533
- )}
1534
- </span>
1535
- </div>
1536
- ))}
1537
- </div>
1538
- )}
1539
- </div>
1540
- )}
1541
-
1542
- {/* Select Tour Option - grid of option buttons (duration + starting price) */}
1543
- {selectedDate && optionsAvailableForSelectedDate.length > 0 && (
1544
- <div>
1545
- <label className="block text-sm font-medium text-stone-700 mb-2">
1546
- {t('booking.selectTourOption')}
1547
- </label>
1548
- <div className="grid grid-cols-2 gap-2">
1549
- {[...optionsAvailableForSelectedDate]
1550
- .sort((a, b) => (b.mostPopular ? 1 : 0) - (a.mostPopular ? 1 : 0))
1551
- .map((option) => {
1552
- const isSelected = selectedOption === option.optionId;
1553
- const baseDurationMinutes = option.privateShuttleConfig?.baseDurationMinutes ?? 0;
1554
- const hours = baseDurationMinutes / 60;
1555
- const price = getOptionPrice(option.optionId);
1556
- const formattedPrice = formatCurrencyAmount(price, currency);
1557
- const hoursRounded = Math.round(hours);
1558
- const isMostPopular = optionsAvailableForSelectedDate.length > 1 && option.mostPopular;
1559
- const priceHoursLine = price > 0 && hours >= 1
1560
- ? (t('booking.startingAtForHours') ?? 'Starting at {price} per shuttle for {hours} hours.')
1561
- .replace('{price}', formattedPrice)
1562
- .replace('{hours}', String(hoursRounded))
1563
- : null;
1564
- return (
1565
- <button
1566
- key={option.optionId}
1567
- onClick={() => handleOptionSelect(option.optionId)}
1568
- className={`${isMostPopular ? 'pt-5 sm:pt-4' : 'pt-3'} pb-3 px-4 rounded-lg text-sm font-medium transition-all relative text-left ${
1569
- isSelected
1570
- ? 'bg-emerald-600 text-white'
1571
- : 'bg-stone-100 text-stone-700 hover:bg-stone-200'
1572
- }`}
1573
- >
1574
- <div className="font-semibold">{option.name}</div>
1575
- {priceHoursLine && (
1576
- <div className={`mt-1 text-xs ${isSelected ? 'text-emerald-100' : 'text-stone-500'}`}>
1577
- {(() => {
1578
- const template = t('booking.startingAtForHours') ?? 'Starting at {price} per shuttle for {hours}.';
1579
- const parts = template.split(/\{price\}|\{hours\}/);
1580
- const placeholders = template.match(/\{price\}|\{hours\}/g) || [];
1581
- const result: React.ReactNode[] = [];
1582
- parts.forEach((part, i) => {
1583
- result.push(part);
1584
- if (i < placeholders.length) {
1585
- const value = placeholders[i] === '{price}' ? formattedPrice : `${hoursRounded} ${t('booking.hoursUnit') || 'hours'}`;
1586
- result.push(<b key={i}>{value}</b>);
1587
- }
1588
- });
1589
- return result;
1590
- })()}
1591
- </div>
1592
- )}
1593
- {priceHoursLine && (
1594
- <div className={`mt-0.5 text-[11px] ${isSelected ? 'text-emerald-200/90' : 'text-stone-400'}`}>
1595
- {t('booking.additionalHoursAvailable')}
1596
- </div>
1597
- )}
1598
- {isMostPopular && (
1599
- <div className="absolute -top-1 left-1/2 -translate-x-1/2 text-white text-[10px] font-semibold px-2.5 py-0.5 rounded-full whitespace-nowrap" style={{ backgroundColor: '#ff4d00' }}>
1600
- {t('booking.mostPopular')}
1601
- </div>
1602
- )}
1603
- </button>
1604
- );
1605
- })}
1606
- </div>
1607
- </div>
1608
- )}
1609
-
1610
- {/* Passenger Count - Step 2: shown after option selection. Max is based on available resources (vacancies). */}
1611
- {selectedOption && selectedAvailability && (
1612
- <div>
1613
- <label className="block text-sm font-medium text-stone-700 mb-4">
1614
- Number of Passengers
1615
- </label>
1616
- <div className="p-4 bg-stone-50 rounded-lg">
1617
- <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
1618
- <div>
1619
- <div className="font-medium text-stone-900">Passengers</div>
1620
- <div className="text-sm text-stone-600">
1621
- {resourceCount} {resourceCount === 1 ? 'shuttle' : 'shuttles'} needed
1622
- </div>
1623
- </div>
1624
- <select
1625
- value={passengerCount}
1626
- onChange={(e) => handlePassengerCountChange(Number(e.target.value))}
1627
- className="w-16 px-2 py-1.5 text-center border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-stone-400 focus:border-stone-500 bg-white"
1628
- >
1629
- {Array.from({ length: Math.max(1, selectedAvailability?.vacancies || 0) }, (_, i) => i + 1).map((n) => (
1630
- <option key={n} value={n}>
1631
- {n}
1632
- </option>
1633
- ))}
1634
- </select>
1635
- </div>
1636
- <div className="text-xs text-stone-500 mt-2">
1637
- Each shuttle can accommodate up to {RESOURCE_CAPACITY} passengers.
1638
- {resourceCount > 1 && (
1639
- <span> You&apos;ll need {resourceCount} shuttles for {passengerCount} passengers.</span>
1640
- )}
1641
- </div>
1642
- </div>
1643
- </div>
1644
- )}
1645
-
1646
- {/* Pickup Time Selection - suggested times + option to request custom */}
1647
- {selectedOption && selectedAvailability && passengerCount > 0 && (
1648
- <div>
1649
- <label className="block text-sm font-medium text-stone-700 mb-2">
1650
- {t('booking.selectPickupTime')}
1651
- </label>
1652
- {(suggestedStartTimes.length > 0 || isCustomTimeMode) && (
1653
- <div className="flex flex-wrap gap-2">
1654
- {suggestedStartTimes.map((time) => {
1655
- const isSelected = !isCustomTimeMode && selectedStartTime === time;
1656
- const [hours, minutes] = time.split(':').map(Number);
1657
- const timeDate = new Date();
1658
- timeDate.setHours(hours, minutes, 0, 0);
1659
- const displayTime = format(timeDate, 'h:mm a');
1660
- return (
1661
- <button
1662
- key={time}
1663
- type="button"
1664
- onClick={() => handleStartTimeSelect(time)}
1665
- className={`py-2 px-4 rounded-full text-sm font-medium transition-all ${
1666
- isSelected
1667
- ? 'bg-emerald-600 text-white'
1668
- : 'bg-stone-100 text-stone-700 hover:bg-stone-200'
1669
- }`}
1670
- >
1671
- {displayTime}
1672
- </button>
1673
- );
1674
- })}
1675
- {suggestedStartTimes.length > 0 && (
1676
- <button
1677
- type="button"
1678
- onClick={handleCustomTimeRequest}
1679
- className={`py-2 px-4 rounded-full text-sm font-medium transition-all ${
1680
- isCustomTimeMode
1681
- ? 'bg-emerald-600 text-white'
1682
- : 'bg-stone-100 text-stone-700 hover:bg-stone-200'
1683
- }`}
1684
- >
1685
- {t('booking.requestDifferentTime')}
1686
- </button>
1687
- )}
1688
- </div>
1689
- )}
1690
- {(isCustomTimeMode || suggestedStartTimes.length === 0) && (
1691
- <div className="mt-3">
1692
- <label htmlFor="custom-pickup-time" className="block text-xs text-stone-500 mb-1">
1693
- {suggestedStartTimes.length === 0 ? t('booking.preferredPickupTime') : t('booking.requestDifferentTime')}
1694
- </label>
1695
- <input
1696
- id="custom-pickup-time"
1697
- type="time"
1698
- value={selectedStartTime}
1699
- onChange={(e) => handleCustomTimeChange(e.target.value)}
1700
- className="px-3 py-2 border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-stone-400 focus:border-stone-500"
1701
- />
1702
- </div>
1703
- )}
1704
- </div>
1705
- )}
1706
-
1707
- {/* Itinerary Builder - Step 3: destinations, lunch stop, planning notes */}
1708
- {selectedOption && passengerCount > 0 && product.itineraryBuilder && (
1709
- <div className="border-t border-stone-200 pt-6 space-y-6">
1710
- <ItineraryBuilder
1711
- destinations={product.itineraryBuilder.destinations}
1712
- optionBlacklist={privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist || []}
1713
- selectedDestinationIds={draftItineraryDestinations}
1714
- planningNotes={draftItineraryPlanningNotes}
1715
- onDestinationsChange={setDraftItineraryDestinations}
1716
- onPlanningNotesChange={setDraftItineraryPlanningNotes}
1717
- />
1718
- {/* Admin only: add on hours ($170/hour, extends duration) */}
1719
- {isAdmin && (
1720
- <div className="p-4 bg-amber-50/50 border border-amber-200 rounded-lg">
1721
- <label className="block text-sm font-medium text-stone-700 mb-2">
1722
- Add on hours (admin)
1723
- </label>
1724
- <p className="text-sm text-stone-600 mb-3">
1725
- Each additional hour adds {formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency)} and extends the tour duration.
1726
- </p>
1727
- <div className="flex items-center gap-3">
1728
- <button
1729
- type="button"
1730
- onClick={() => setAdditionalHoursCount((c) => Math.max(0, c - 1))}
1731
- disabled={additionalHoursCount <= 0}
1732
- className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
1733
- >
1734
-
1735
- </button>
1736
- <span className="w-8 text-center font-medium tabular-nums">{additionalHoursCount}</span>
1737
- <button
1738
- type="button"
1739
- onClick={() => setAdditionalHoursCount((c) => c + 1)}
1740
- className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 font-medium"
1741
- >
1742
- +
1743
- </button>
1744
- <span className="text-sm text-stone-600">
1745
- {additionalHoursCount === 0
1746
- ? 'No extra hours'
1747
- : additionalHoursCount === 1
1748
- ? `+1 hour • ${formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency)}`
1749
- : `+${additionalHoursCount} hours • ${formatCurrencyAmount(additionalHoursAmount, currency)}`}
1750
- </span>
1751
- </div>
1752
- </div>
1753
- )}
1754
- </div>
1755
- )}
1756
-
1757
- {/* Safety seats - Step 4 */}
1758
- {selectedOption && passengerCount > 0 && (
1759
- <div className="border-t border-stone-200 pt-6">
1760
- <label className="block text-sm font-medium text-stone-700 mb-2">
1761
- Safety seats for kids
1762
- </label>
1763
- <p className="text-sm text-stone-500 mb-2">How many child safety seats do you need?</p>
1764
- <div className="flex items-center gap-2">
1765
- <button
1766
- type="button"
1767
- onClick={() => setChildSafetySeatsCount((c) => Math.max(0, c - 1))}
1768
- disabled={childSafetySeatsCount <= 0}
1769
- className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed"
1770
- >
1771
-
1772
- </button>
1773
- <span className="w-8 text-center font-medium tabular-nums">{childSafetySeatsCount}</span>
1774
- <button
1775
- type="button"
1776
- onClick={() => setChildSafetySeatsCount((c) => Math.min(passengerCount, c + 1))}
1777
- disabled={childSafetySeatsCount >= passengerCount}
1778
- className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed"
1779
- >
1780
- +
1781
- </button>
1782
- </div>
1783
- </div>
1784
- )}
1785
-
1786
- {/* Food restrictions - Step 5 */}
1787
- {selectedOption && passengerCount > 0 && (
1788
- <div className="border-t border-stone-200 pt-6">
1789
- <label htmlFor="food-restrictions" className="block text-sm font-medium text-stone-700 mb-2">
1790
- Food restrictions
1791
- </label>
1792
- <p className="text-sm text-stone-500 mb-2">Shuttle includes croissants, coffee, tea, hot chocolate, trail snacks.</p>
1793
- <textarea
1794
- id="food-restrictions"
1795
- value={foodRestrictions}
1796
- onChange={(e) => setFoodRestrictions(e.target.value)}
1797
- placeholder="Any dietary restrictions or allergies?"
1798
- rows={2}
1799
- className="w-full px-3 py-2 border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-stone-400 focus:border-stone-500 text-sm"
1800
- />
1801
- </div>
1802
- )}
1803
-
1804
- {/* Add-ons - Step 6: EL lunch (only when Emerald Lake selected), Animals */}
1805
- {selectedOption && passengerCount > 0 && addOns.length > 0 && (
1806
- <div className="border-t border-stone-200 pt-6 space-y-4">
1807
- {/* Emerald Lake Lunch Package - only when emerald_lake is in destinations */}
1808
- {draftItineraryDestinations.includes('emerald_lake') && (() => {
1809
- const lunchAddOn = addOns.find((a) => a.addOnId === 'addon_el_lunch');
1810
- if (!lunchAddOn || !canUseMealDrinkSelector(lunchAddOn)) return null;
1811
- return (
1812
- <MealDrinkAddOnSelector
1813
- addOn={lunchAddOn}
1814
- selections={addOnSelections}
1815
- onSelectionsChange={setAddOnSelections}
1816
- currency={currency}
1817
- locale={locale}
1818
- />
1819
- );
1820
- })()}
1821
- {/* Animals - $50 cleaning fee */}
1822
- {(() => {
1823
- const animalsAddOn = addOns.find((a) => a.addOnId === 'addon_animals');
1824
- if (!animalsAddOn) return null;
1825
- const isAnimalsSelected = addOnSelections.some((s) => s.addOnId === 'addon_animals');
1826
- return (
1827
- <div>
1828
- <label className="block text-sm font-medium text-stone-700 mb-2">
1829
- Animals?
1830
- </label>
1831
- <p className="text-sm text-stone-500 mb-2">{animalsAddOn.description || 'Cleaning fee for traveling with animals'}</p>
1832
- <button
1833
- type="button"
1834
- onClick={() => {
1835
- setAddOnSelections((prev) => {
1836
- if (isAnimalsSelected) return prev.filter((s) => s.addOnId !== 'addon_animals');
1837
- return [...prev, { addOnId: 'addon_animals', quantity: 1 }];
1838
- });
1839
- }}
1840
- className={`flex items-center justify-between w-full p-4 rounded-lg border-2 text-left transition-colors ${
1841
- isAnimalsSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
1842
- }`}
1843
- >
1844
- <span className="font-medium text-stone-900">
1845
- {isAnimalsSelected ? 'Yes, traveling with animals' : 'No animals'}
1846
- </span>
1847
- <span className="text-sm font-semibold text-stone-700">
1848
- +{formatCurrencyAmount(animalsAddOn.price || 0, currency, locale)}
1849
- </span>
1850
- </button>
1851
- </div>
1852
- );
1853
- })()}
1854
- </div>
1855
- )}
1856
-
1857
- {/* Pickup Location Selection - Step 6 */}
1858
- {selectedOption && passengerCount > 0 &&
1859
- product.pickupLocations &&
1860
- product.pickupLocations.length > 0 && (
1861
- <div id="pickup-location-section" className="border-t border-stone-200 pt-6">
1862
- {pickupLocationId || customPickupAddress || pickupLocationSkipped ? (
1863
- <div className="space-y-4">
1864
- <div className="flex items-center justify-between">
1865
- <div>
1866
- <label className="block text-sm font-medium text-stone-700 mb-1">
1867
- {t('pickup.pickupLocation') || 'Pickup Location'}
1868
- </label>
1869
- {pickupLocationSkipped ? (
1870
- <p className="text-sm text-stone-900">
1871
- {t('booking.pickupLocationUnknown')}
1872
- </p>
1873
- ) : customPickupAddress ? (
1874
- <p className="text-sm text-stone-900">
1875
- {customPickupAddress}
1876
- </p>
1877
- ) : (
1878
- <>
1879
- <p className="text-sm text-stone-900">
1880
- {selectedPickupLocation?.name}
1881
- </p>
1882
- <p className="text-xs text-stone-500">
1883
- {selectedPickupLocation?.address}
1884
- </p>
1885
- {selectedPickupLocation?.notes && (
1886
- <p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mt-2">
1887
- {selectedPickupLocation.notes}
1888
- </p>
1889
- )}
1890
- </>
1891
- )}
1892
- </div>
1893
- <button
1894
- onClick={() => {
1895
- setPickupLocationId(null);
1896
- setCustomPickupAddress(null);
1897
- setPickupLocationSkipped(false);
1898
- setSelectedStartTime('');
1899
- setIsCustomTimeMode(false);
1900
- }}
1901
- className="text-sm text-emerald-600 hover:text-emerald-700 underline"
1902
- >
1903
- {t('common.change') || 'Change'}
1904
- </button>
1905
- </div>
1906
- </div>
1907
- ) : (
1908
- <PickupLocationSelector
1909
- pickupLocations={product.pickupLocations}
1910
- selectedLocationId={pickupLocationId}
1911
- selectedCustomAddress={customPickupAddress}
1912
- allowCustomLocation
1913
- isSkipped={pickupLocationSkipped}
1914
- destinations={product.destinations}
1915
- onLocationSelect={(locationId, customLocation) => {
1916
- setError('');
1917
- if (customLocation) {
1918
- setPickupLocationId(null);
1919
- setCustomPickupAddress(customLocation.address);
1920
- setPickupLocationSkipped(false);
1921
- } else {
1922
- setPickupLocationId(locationId);
1923
- setCustomPickupAddress(null);
1924
- if (locationId === null && pickupLocationSkipped) {
1925
- setPickupLocationSkipped(false);
1926
- } else if (locationId !== null) {
1927
- setPickupLocationSkipped(false);
1928
- }
1929
- }
1930
- }}
1931
- onSkip={() => {
1932
- setPickupLocationSkipped(true);
1933
- setPickupLocationId(null);
1934
- setCustomPickupAddress(null);
1935
- setError('');
1936
- }}
1937
- />
1938
- )}
1939
- </div>
1940
- )}
1941
-
1942
- {/* Price Summary, Contact, Terms & Checkout — below pickup location */}
1943
- {selectedStartTime && passengerCount > 0 && (
1944
- <div className="border-t border-stone-200 pt-6 mt-6 space-y-4">
1945
- {/* Cancellation policy - forced by promo or user selection */}
1946
- {(forcedCancellationPolicyFromPromo || (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0)) && (
1947
- <div>
1948
- <div className="text-sm font-medium text-stone-700 mb-2">
1949
- {t('booking.cancellationPolicy')}
1950
- </div>
1951
- {forcedCancellationPolicyFromPromo ? (
1952
- <div className="flex items-center gap-3 p-4 rounded-lg border-2 border-amber-200 bg-amber-50">
1953
- <Check className="h-5 w-5 shrink-0 text-amber-600" />
1954
- <div>
1955
- <span className="font-medium text-stone-900">{forcedCancellationPolicyFromPromo.label}</span>
1956
- <p className="text-sm text-stone-600 mt-0.5">{t('booking.promoRequiresThisPolicy')}</p>
1957
- </div>
1958
- </div>
1959
- ) : (
1960
- <div className="space-y-2">
1961
- {pricingConfig!.cancellationPolicies!.map((policy) => {
1962
- const fee = policy.feeByCurrency[currency] ?? 0;
1963
- const isSelected = cancellationPolicyId === policy.id;
1964
- const isFree = fee === 0;
1965
- return (
1966
- <button
1967
- key={policy.id}
1968
- type="button"
1969
- onClick={() => setCancellationPolicyId(policy.id)}
1970
- className={`w-full flex items-center justify-between gap-3 p-4 rounded-lg border-2 text-left transition-colors ${
1971
- isSelected
1972
- ? 'border-emerald-500 bg-emerald-50'
1973
- : 'border-stone-200 bg-white hover:border-stone-300'
1974
- }`}
1975
- >
1976
- <div className="flex items-center gap-3">
1977
- <span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border ${
1978
- isSelected ? 'border-emerald-500 bg-emerald-500' : 'border-stone-300'
1979
- }`}>
1980
- {isSelected && <Check className="h-3 w-3 text-white" />}
1981
- </span>
1982
- <span className="font-medium text-stone-900">
1983
- {policy.label}
1984
- </span>
1985
- </div>
1986
- <span className={`text-sm whitespace-nowrap ${isFree ? 'text-stone-500' : 'font-semibold text-stone-700'}`}>
1987
- {isFree ? (t('booking.included') ?? 'Included') : `+${formatCurrencyAmount(fee, currency, locale)}`}
1988
- </span>
1989
- </button>
1990
- );
1991
- })}
1992
- </div>
1993
- )}
1994
- </div>
1995
- )}
1996
- {/* Duration Info */}
1997
- {calculatedDuration > 0 && (
1998
- <div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
1999
- <div className="text-sm text-stone-600 mb-1">Duration</div>
2000
- <div className="text-lg font-semibold text-emerald-900">
2001
- {Math.floor(calculatedDuration / 60)}h {calculatedDuration % 60}m
2002
- </div>
2003
- {calculatedEndTime && selectedStartTime && (
2004
- <div className="text-sm text-stone-600 mt-1">
2005
- Start: {(() => {
2006
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
2007
- const timeDate = new Date();
2008
- timeDate.setHours(hours, minutes);
2009
- return format(timeDate, 'h:mm a');
2010
- })()} • End: {calculatedEndTime}
2011
- </div>
2012
- )}
2013
- </div>
2014
- )}
2015
- <PriceSummary
2016
- lines={[
2017
- {
2018
- kind: 'line',
2019
- label: `Shuttle${resourceCount > 1 ? 's' : ''} (${resourceCount} × ${formatCurrencyAmount(resourcePrice, currency)})`,
2020
- amount: basePrice,
2021
- type: 'TICKET',
2022
- quantity: resourceCount,
2023
- },
2024
- ...addOnSelections.map((sel) => {
2025
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
2026
- if (!addOn) return null;
2027
- const base = addOn.price ?? 0;
2028
- const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
2029
- const adj = hasVariant
2030
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
2031
- : 0;
2032
- const amt = (base + adj) * (sel.quantity ?? 1);
2033
- const variantLabel = hasVariant
2034
- ? addOn.variants?.find((v) => v.id === sel.variantId)?.label
2035
- : null;
2036
- const qty = sel.quantity ?? 1;
2037
- return { kind: 'line' as const, label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name, amount: amt, type: 'FEE' as const };
2038
- }).filter(Boolean) as Array<{ kind: 'line'; label: string; amount: number; type: string }>,
2039
- ...(additionalHoursAmount > 0
2040
- ? [{ kind: 'line' as const, label: additionalHoursCount === 1 ? 'Additional hour' : `Additional hours (${additionalHoursCount})`, amount: additionalHoursAmount, type: 'FEE' as const }]
2041
- : []),
2042
- ...(cancellationPolicyFee > 0 && selectedCancellationPolicy
2043
- ? [{ kind: 'line' as const, label: selectedCancellationPolicy.label, amount: cancellationPolicyFee, type: 'cancellation' as const }]
2044
- : []),
2045
- ...(taxAmount > 0
2046
- ? [{ kind: 'line' as const, label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees', amount: taxAmount, type: 'TAX' as const }]
2047
- : []),
2048
- ]}
2049
- total={totalPrice}
2050
- currency={currency}
2051
- locale={locale}
2052
- size="sm"
2053
- subtotal={taxAmount > 0 || promoDiscountAmount > 0 ? subtotal : undefined}
2054
- discountAmount={promoDiscountAmount}
2055
- discountLabel={appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined}
2056
- t={t}
2057
- extraAfterTotal={isChangeMode && initialBooking?.originalTotalAmount != null && initialBooking.originalCurrency === currency ? (
2058
- <div className="pt-3 mt-2 border-t border-stone-200 space-y-2">
2059
- <div className="flex justify-between gap-3 min-w-0 text-sm">
2060
- <span className="text-stone-600 min-w-0 truncate">Price change</span>
2061
- <span className={`flex-shrink-0 whitespace-nowrap font-medium ${totalPrice - initialBooking.originalTotalAmount >= 0 ? 'text-stone-700' : 'text-red-600'}`}>
2062
- {totalPrice - initialBooking.originalTotalAmount >= 0 ? '+' : ''}
2063
- {formatCurrencyAmount(totalPrice - initialBooking.originalTotalAmount, currency, locale)}
2064
- </span>
2065
- </div>
2066
- <div className="flex justify-between gap-3 min-w-0 text-xs text-stone-500">
2067
- <span>Original: {formatCurrencyAmount(initialBooking.originalTotalAmount, currency, locale)}</span>
2068
- <span>→ New: {formatCurrencyAmount(totalPrice, currency, locale)}</span>
2069
- </div>
2070
- {Math.abs(totalPrice - initialBooking.originalTotalAmount) > 0.01 && (
2071
- <label className="flex items-center gap-2 text-sm text-stone-600 cursor-pointer">
2072
- <input
2073
- type="checkbox"
2074
- checked={keepOriginalPrice}
2075
- onChange={(e) => setKeepOriginalPrice(e.target.checked)}
2076
- className="rounded border-stone-300 text-stone-700 focus:ring-stone-500"
2077
- />
2078
- <span>Keep original price (no charge or refund)</span>
2079
- </label>
2080
- )}
2081
- </div>
2082
- ) : undefined}
2083
- extraBetweenTaxAndTotal={
2084
- isAdmin ? (
2085
- <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 pt-2 border-t border-stone-200">
2086
- <label htmlFor="private-shuttle-promo-code" className="flex-shrink-0 text-sm font-medium text-stone-500 whitespace-nowrap">
2087
- {t('booking.optionalPromoCode') || 'Promo / voucher / gift card'}
2088
- </label>
2089
- <div className="flex-1 min-w-0 w-full sm:w-auto flex items-center gap-2 flex-wrap">
2090
- <div className="relative flex-1 w-full sm:max-w-[200px] min-w-0 sm:min-w-[120px]">
2091
- <input
2092
- type="text"
2093
- name="promoCode"
2094
- id="private-shuttle-promo-code"
2095
- value={promoCodeInput}
2096
- onChange={(e) => {
2097
- setPromoCodeInput(e.target.value.toUpperCase());
2098
- setPromoCodeError('');
2099
- }}
2100
- onKeyDown={(e) => {
2101
- if (e.key === 'Enter') {
2102
- e.preventDefault();
2103
- handleApplyPromo();
2104
- }
2105
- }}
2106
- onPaste={() => {
2107
- setTimeout(() => handleApplyPromoRef.current(), 50);
2108
- }}
2109
- placeholder={t('booking.promoCodePlaceholder')}
2110
- autoComplete="off"
2111
- readOnly={!!appliedPromoCode}
2112
- className="w-full pr-8 pl-3 py-1.5 text-sm rounded border border-stone-300 bg-white focus:outline-none focus:border-stone-500 text-stone-900 read-only:bg-stone-50 read-only:cursor-default"
2113
- />
2114
- {appliedPromoCode ? (
2115
- <button
2116
- type="button"
2117
- onClick={() => {
2118
- setAppliedPromoCode(null);
2119
- setPromoCodeInput('');
2120
- setPromoCodeError('');
2121
- setForcedCancellationPolicyFromPromo(null);
2122
- fetchedRangesRef.current = [];
2123
- }}
2124
- className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-stone-200 text-stone-500 hover:text-stone-700"
2125
- aria-label={t('booking.removePromo')}
2126
- >
2127
- <X className="w-4 h-4" strokeWidth={2.5} />
2128
- </button>
2129
- ) : promoCodeValidating ? (
2130
- <span className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full bg-stone-100 animate-pulse" aria-hidden />
2131
- ) : promoCodeError ? (
2132
- <span className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center" aria-label={promoCodeError}>
2133
- <X className="w-3 h-3" strokeWidth={3} />
2134
- </span>
2135
- ) : null}
2136
- </div>
2137
- {appliedPromoCode && (
2138
- <span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center" aria-label={t('booking.promoApplied', { code: appliedPromoCode })}>
2139
- <Check className="w-3.5 h-3.5" strokeWidth={3} />
2140
- </span>
2141
- )}
2142
- {promoDiscountAmount > 0 && (
2143
- <span className="text-sm font-medium text-red-600 whitespace-nowrap ml-auto">
2144
- -{formatCurrencyAmount(promoDiscountAmount, currency, locale)}
2145
- </span>
2146
- )}
2147
- {promoCodeError && (
2148
- <span className="text-sm text-red-600 whitespace-nowrap ml-auto">{promoCodeError}</span>
2149
- )}
2150
- {forcedCancellationPolicyFromPromo && (
2151
- <p className="w-full text-sm text-stone-600 mt-2 flex items-center gap-1.5">
2152
- <span className="inline-flex items-center rounded bg-amber-50 px-2 py-0.5 text-amber-800 ring-1 ring-amber-200">
2153
- {t('booking.promoRequiresCancellationPolicy', { policy: forcedCancellationPolicyFromPromo.label })}
2154
- </span>
2155
- </p>
2156
- )}
2157
- </div>
2158
- </div>
2159
- ) : undefined
2160
- }
2161
- />
2162
- {depositInfo && (
2163
- <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 space-y-2">
2164
- <div className="text-sm font-medium text-amber-900">Payment Plan</div>
2165
- <div className="flex justify-between text-sm">
2166
- <span className="text-amber-700">Deposit (Due Now)</span>
2167
- <span className="font-semibold text-amber-900">
2168
- {formatCurrencyAmount(depositInfo.depositAmount, currency)}
2169
- </span>
2170
- </div>
2171
- <div className="flex justify-between text-sm">
2172
- <span className="text-amber-700">Balance (Due Later)</span>
2173
- <span className="text-amber-900">
2174
- {formatCurrencyAmount(depositInfo.balanceAmount, currency)}
2175
- </span>
2176
- </div>
2177
- <div className="text-xs text-amber-600 mt-2">
2178
- Balance will be charged {privateShuttleConfig?.balanceChargeDaysBefore || 7} days before your booking
2179
- </div>
2180
- </div>
2181
- )}
2182
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
2183
- <div>
2184
- <label className="block text-sm font-medium text-stone-700 mb-2">
2185
- {t('booking.firstName') || 'First Name'}
2186
- </label>
2187
- <input
2188
- type="text"
2189
- name="firstName"
2190
- id="private-shuttle-firstName"
2191
- value={firstName}
2192
- onChange={(e) => { setFirstName(e.target.value); setError(''); }}
2193
- placeholder={t('booking.firstNamePlaceholder') || 'Jane'}
2194
- autoComplete="given-name"
2195
- className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
2196
- />
2197
- </div>
2198
- <div>
2199
- <label className="block text-sm font-medium text-stone-700 mb-2">
2200
- {t('booking.lastName') || 'Last Name'} <span className="text-red-600">*</span>
2201
- </label>
2202
- <input
2203
- type="text"
2204
- name="lastName"
2205
- id="private-shuttle-lastName"
2206
- value={lastName}
2207
- onChange={(e) => { setLastName(e.target.value); setError(''); }}
2208
- placeholder={t('booking.lastNamePlaceholder') || 'Smith'}
2209
- autoComplete="family-name"
2210
- required
2211
- className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
2212
- />
2213
- </div>
2214
- </div>
2215
- <div>
2216
- <label className="block text-sm font-medium text-stone-700 mb-2">
2217
- {t('booking.emailForConfirmation')} <span className="text-red-600">*</span>
2218
- </label>
2219
- <input
2220
- type="email"
2221
- name="email"
2222
- id="private-shuttle-email"
2223
- value={email}
2224
- onChange={(e) => { setEmail(e.target.value); setError(''); }}
2225
- placeholder={t('common.emailPlaceholder')}
2226
- autoComplete="email"
2227
- required
2228
- className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
2229
- />
2230
- {email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && (
2231
- <p className="text-xs text-red-600 mt-1.5">{t('booking.invalidEmail') || 'Please enter a valid email address'}</p>
2232
- )}
2233
- </div>
2234
- {isAdmin && (
2235
- <div className="p-4 bg-amber-50/50 border border-amber-200 rounded-lg space-y-3">
2236
- <p className="text-sm font-medium text-stone-700">Communications (admin)</p>
2237
- <label className="flex items-start gap-2 cursor-pointer">
2238
- <input
2239
- type="checkbox"
2240
- checked={skipConfirmationCommunications}
2241
- onChange={(e) => setSkipConfirmationCommunications(e.target.checked)}
2242
- className="mt-1 h-4 w-4 rounded border-stone-300"
2243
- />
2244
- <span className="text-sm text-stone-600">Don&apos;t send confirmation email/SMS/WhatsApp for this booking</span>
2245
- </label>
2246
- <label className="flex items-start gap-2 cursor-pointer">
2247
- <input
2248
- type="checkbox"
2249
- checked={disableAutoCommunications}
2250
- onChange={(e) => setDisableAutoCommunications(e.target.checked)}
2251
- className="mt-1 h-4 w-4 rounded border-stone-300"
2252
- />
2253
- <span className="text-sm text-stone-600">Disable all auto communications for this booking</span>
2254
- </label>
2255
- </div>
2256
- )}
2257
- <div className="p-4 bg-stone-50 rounded-lg border border-stone-200">
2258
- <TermsAcceptance
2259
- checked={termsAccepted}
2260
- onChange={(checked) => {
2261
- setTermsAccepted(checked);
2262
- setTermsAcceptedAt(checked ? new Date().toISOString() : null);
2263
- }}
2264
- t={t}
2265
- />
2266
- </div>
2267
- {error && (
2268
- <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
2269
- {error}
2270
- </div>
2271
- )}
2272
- <button
2273
- onClick={handleCheckout}
2274
- disabled={loading || !termsAccepted}
2275
- className="w-full bg-emerald-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
2276
- >
2277
- {loading
2278
- ? 'Processing...'
2279
- : depositInfo
2280
- ? `Pay Deposit (${formatCurrencyAmount(depositInfo.depositAmount, currency, locale)})`
2281
- : `Book Now (${formatCurrencyAmount(totalPrice, currency, locale)})`}
2282
- </button>
2283
- </div>
2284
- )}
2285
- </>
2286
- )}
2287
- </div>
2288
- );
2289
- }
2290
-