@ticketboothapp/booking 1.2.123 → 1.2.125

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 (112) hide show
  1. package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
  2. package/package.json +1 -1
  3. package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +651 -6202
  5. package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
  6. package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
  7. package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
  8. package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
  9. package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
  10. package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
  11. package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
  12. package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
  13. package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
  14. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
  15. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
  16. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
  17. package/src/components/booking/CheckoutModal.tsx +68 -49
  18. package/src/components/booking/NewBookingFlow.tsx +485 -3505
  19. package/src/components/booking/PriceBreakdown.tsx +17 -3
  20. package/src/components/booking/PriceSummary.tsx +21 -3
  21. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
  22. package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
  23. package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
  24. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
  25. package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
  26. package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
  27. package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
  29. package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
  30. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
  31. package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
  32. package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
  33. package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
  34. package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
  35. package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
  36. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
  37. package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
  38. package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
  39. package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
  40. package/src/components/booking/admin-change-provider-payload.ts +101 -0
  41. package/src/components/booking/change-booking-checkout-builders.ts +185 -0
  42. package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
  43. package/src/components/booking/change-booking-flow-helpers.ts +706 -0
  44. package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
  45. package/src/components/booking/change-booking-quote-guards.ts +223 -0
  46. package/src/components/booking/change-booking-quote-state.ts +64 -0
  47. package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
  48. package/src/components/booking/private-shuttle-availability.ts +111 -0
  49. package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
  50. package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
  51. package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
  52. package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
  53. package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
  54. package/src/components/booking/standard-booking-availability.ts +305 -0
  55. package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
  56. package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
  57. package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
  58. package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
  59. package/src/components/booking/use-private-shuttle-availability.ts +602 -0
  60. package/src/components/booking/use-standard-booking-availability.ts +806 -0
  61. package/src/components/booking/useActiveProductOptions.ts +26 -0
  62. package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
  63. package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
  64. package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
  66. package/src/components/booking/useAdminChangeProductReset.ts +60 -0
  67. package/src/components/booking/useAdminChangeProducts.ts +102 -0
  68. package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
  69. package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
  70. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
  71. package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
  72. package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
  73. package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
  74. package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
  75. package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
  76. package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
  77. package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
  78. package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
  79. package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
  80. package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
  81. package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
  82. package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
  83. package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
  84. package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
  85. package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
  86. package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
  87. package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
  88. package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
  89. package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
  90. package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
  91. package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
  92. package/src/components/booking/useChangeBookingSelection.ts +362 -0
  93. package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
  94. package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
  95. package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
  96. package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
  97. package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
  98. package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
  99. package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
  100. package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
  101. package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
  102. package/src/components/booking/useStandardBookingPromoController.ts +258 -0
  103. package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
  104. package/src/data/products-config.json +1 -1
  105. package/src/lib/booking/change-booking-server-preview.ts +134 -20
  106. package/src/lib/booking/change-flow-pricing.ts +31 -11
  107. package/src/lib/booking/checkout-breakdown.ts +66 -1
  108. package/src/lib/booking/pricing-config.ts +10 -0
  109. package/src/lib/booking/source-metadata.ts +58 -0
  110. package/src/lib/booking-api.ts +155 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -1,76 +1,53 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
4
- import { format, addWeeks, parseISO, isBefore, isAfter, startOfDay, endOfDay } from 'date-fns';
5
- import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
6
4
  import {
7
- getAvailabilities,
8
- createReservation,
9
- cancelReservation,
10
- cancelReservationBestEffort,
11
- createPaymentIntent,
12
- confirmFreeBooking,
13
- confirmBookingWithoutPayment,
14
- confirmPartnerBookingWithoutPayment,
15
5
  getAddOns,
16
- validatePromoCode,
17
- getPromoDiscount,
18
6
  type Product,
19
7
  type Availability,
20
- type ItineraryDisplayStep,
21
8
  type AddOn,
22
- ItineraryStepType,
23
- isInsufficientCapacityReserveError,
24
- describePrivateShuttleCapacityConflictMessage,
25
- reportReserveCapacityConflictClientContext,
26
9
  } from '../../lib/booking-api';
27
- import {
28
- EARLIEST_AVAILABILITY_DATE,
29
- LATEST_AVAILABILITY_DATE,
30
- INITIAL_FETCH_WEEKS,
31
- VISIBLE_RANGE_BUFFER_WEEKS,
32
- } from '../../lib/booking-constants';
33
- import { formatCurrencyAmount } from '../../lib/currency';
34
- import { formatBookingRefForDisplay } from '../../lib/booking-ref';
35
- import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
36
- import {
37
- isDateAfterPublicBookingCutoff,
38
- resolvePublicBookingCutoffMinutes,
39
- } from '../../lib/booking/booking-cutoffs';
40
- import type { PricingConfig, PrecomputedPricesByCategory } from '../../lib/booking-api';
41
10
  import { Calendar } from './Calendar';
42
- import { PickupLocationSelector } from './PickupLocationSelector';
43
11
  import { useTranslations, useLocale } from '../../lib/booking/i18n';
44
12
  import { type Currency } from './CurrencySwitcher';
45
13
  import { useCompanyTimezone } from '../../contexts/CompanyContext';
46
14
  import { useBookingApp } from '../../contexts/BookingAppContext';
47
- import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
48
- import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
49
- import { CancellationPolicySelector } from './CancellationPolicySelector';
50
- import { PriceSummary } from './PriceSummary';
51
- import { TermsAcceptance } from './TermsAcceptance';
52
- import { PromoCodeInput } from './PromoCodeInput';
53
- import { ItineraryBuilder } from './ItineraryBuilder';
54
- import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
55
- import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
56
- import { BookingFlowCollage } from './BookingFlowCollage';
57
- import { TourDescription } from './TourDescription';
58
15
  import { useBookingHost } from '../../runtime';
59
16
  import styles from './PrivateShuttleBookingFlow.module.css';
60
- import {
61
- buildBookingSourceContext,
62
- inferClientBookingSourceFromProductIds,
63
- type BookingSourceMetadata,
64
- } from '../../lib/booking/source-metadata';
65
- import type { VideoSources } from '../../constants/products';
17
+ import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
66
18
  import type { BookingFlowUiOptions } from './booking-flow-ui';
67
- import { optionalCheckoutContactFields } from '../../lib/booking/checkout-contact';
68
- import { CheckoutOptionalPhoneFields } from './CheckoutOptionalPhoneFields';
19
+ import {
20
+ PrivateShuttleAddOnsSection,
21
+ type PrivateShuttleAddOnSelection,
22
+ } from './PrivateShuttleAddOnsSection';
23
+ import { PrivateShuttleCheckoutSection } from './PrivateShuttleCheckoutSection';
24
+ import { PrivateShuttleCheckoutDialogs } from './PrivateShuttleCheckoutDialogs';
25
+ import { PrivateShuttleItinerarySection } from './PrivateShuttleItinerarySection';
26
+ import { PrivateShuttleMediaIntro } from './PrivateShuttleMediaIntro';
27
+ import { PrivateShuttleOptionSection } from './PrivateShuttleOptionSection';
28
+ import { PrivateShuttlePassengerSection } from './PrivateShuttlePassengerSection';
29
+ import { PrivateShuttlePickupSection } from './PrivateShuttlePickupSection';
30
+ import { PrivateShuttlePreferencesSection } from './PrivateShuttlePreferencesSection';
31
+ import { PrivateShuttleStartTimeSection } from './PrivateShuttleStartTimeSection';
32
+ import {
33
+ getPrivateShuttleAvailabilityOptionId,
34
+ privateShuttleBookingCutoffMessage,
35
+ privateShuttleStartTimeAllowed,
36
+ } from './private-shuttle-availability';
37
+ import { usePrivateShuttleAvailability } from './use-private-shuttle-availability';
69
38
  import type {
70
39
  ProviderDashboardChangeBookingPayload,
71
40
  ProviderDashboardInitialBooking,
72
41
  } from './provider-dashboard-change-booking';
73
- import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
42
+ import {
43
+ usePrivateShuttleCheckoutController,
44
+ } from './private-shuttle-checkout-controller';
45
+ import {
46
+ PRIVATE_SHUTTLE_RESOURCE_CAPACITY,
47
+ usePrivateShuttlePriceSummary,
48
+ } from './usePrivateShuttlePriceSummary';
49
+ import { usePrivateShuttlePromoController } from './usePrivateShuttlePromoController';
50
+ import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
74
51
 
75
52
  interface PrivateShuttleBookingFlowProps {
76
53
  product: Product;
@@ -116,125 +93,9 @@ interface PrivateShuttleBookingFlowProps {
116
93
  onChangeBooking?: (data: ProviderDashboardChangeBookingPayload) => Promise<void>;
117
94
  }
118
95
 
119
- const RESOURCE_CAPACITY = 13;
120
- const ADDITIONAL_HOUR_PRICE = 170;
121
- const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
122
- const MORAINE_LAKE_ROAD_ACCESS_FEE_TOOLTIP =
123
- "Since 2025, Parks Canada charges a per-trip fee for License of Occupation at Moraine Lake. This fee contributes towards Parks Canada's Moraine Lake Road operations.";
124
-
125
- function isMoraineLakeRoadAccessFeeName(name: string): boolean {
126
- const normalized = name.toLowerCase();
127
- return (
128
- normalized.includes('moraine') &&
129
- (normalized.includes('access') ||
130
- normalized.includes('road') ||
131
- normalized.includes('license'))
132
- );
133
- }
134
-
135
- function isMoraineLakeLocationText(value: string): boolean {
136
- const normalized = value.toLowerCase().replace(/[_-]+/g, ' ');
137
- return normalized.includes('moraine') && normalized.includes('lake');
138
- }
139
-
140
- function roundMoney(amount: number): number {
141
- return Math.round(amount * 100) / 100;
142
- }
143
-
144
- function parseAvailabilityDateTime(value: string): Date {
145
- // If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
146
- const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
147
- return parseISO(hasExplicitOffset ? value : `${value}Z`);
148
- }
149
-
150
- function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
151
- return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
152
- }
153
-
154
- function privateShuttleStartTimeAllowed(
155
- dateStr: string,
156
- timeStr: string,
157
- timezone: string,
158
- now: Date,
159
- cutoffMinutes?: number | null,
160
- ): boolean {
161
- return isDateAfterPublicBookingCutoff(
162
- privateShuttleStartDateTime(dateStr, timeStr, timezone),
163
- now,
164
- cutoffMinutes,
165
- );
166
- }
167
-
168
- function privateShuttleBookingCutoffMessage(cutoffMinutes?: number | null): string {
169
- const resolvedCutoffMinutes = resolvePublicBookingCutoffMinutes(cutoffMinutes);
170
- if (resolvedCutoffMinutes % 60 === 0) {
171
- return `Private shuttles must be booked at least ${resolvedCutoffMinutes / 60} hours before departure.`;
172
- }
173
- return `Private shuttles must be booked at least ${resolvedCutoffMinutes} minutes before departure.`;
174
- }
175
-
176
- function findMergedPrivateShuttleAvailability(
177
- merged: Availability[],
178
- sel: Availability | null,
179
- optionId: string | null,
180
- selectedDateStr: string | null,
181
- tz: string
182
- ): Availability | undefined {
183
- if (!sel || !optionId || !selectedDateStr) return undefined;
184
- const oidMatches = (a: Availability) => getPrivateShuttleAvailabilityOptionId(a) === optionId;
185
- const availId = sel.availabilityId?.trim();
186
- if (availId) {
187
- const hit = merged.find((a) => a.availabilityId === availId && oidMatches(a));
188
- if (hit) return hit;
189
- }
190
- const onDay = merged.filter((a) => {
191
- if (!oidMatches(a)) return false;
192
- const day = DATE_ONLY_REGEX.test(a.dateTime)
193
- ? a.dateTime
194
- : formatInTimeZone(parseAvailabilityDateTime(a.dateTime), tz, 'yyyy-MM-dd');
195
- return day === selectedDateStr;
196
- });
197
- return onDay[0];
198
- }
199
-
200
- function getPrivateShuttleAvailabilityOptionId(availability: Availability): string {
201
- const productOptionId = availability.productOptionId?.trim();
202
- if (productOptionId) return productOptionId;
203
- const productId = availability.productId?.trim();
204
- return productId?.startsWith('po_') ? productId : '';
205
- }
206
-
207
- function getPrivateShuttleAvailabilityCacheKey(availability: Availability): string {
208
- return `${availability.dateTime}-${getPrivateShuttleAvailabilityOptionId(availability)}`;
209
- }
210
-
211
- function getResourceRate(availability: Availability | undefined | null) {
212
- return availability?.rates?.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
213
- }
214
-
215
- function applyResourceAdjustments(
216
- basePrice: number,
217
- availability: Availability | undefined | null,
218
- currency: Currency
219
- ): number {
220
- const resourceRate = getResourceRate(availability);
221
- const adjustmentTotal =
222
- resourceRate?.appliedAdjustments?.reduce(
223
- (sum, adjustment) => sum + (adjustment.changeByCurrency?.[currency] ?? 0),
224
- 0
225
- ) ??
226
- resourceRate?.applied_adjustments?.reduce(
227
- (sum, adjustment) => sum + (adjustment.changeByCurrency?.[currency] ?? 0),
228
- 0
229
- ) ??
230
- 0;
231
- return basePrice + adjustmentTotal;
232
- }
233
-
234
96
  export function PrivateShuttleBookingFlow({
235
97
  product,
236
98
  productId,
237
- onBack: _onBack,
238
99
  currency,
239
100
  contentRef,
240
101
  onSuccess,
@@ -250,7 +111,7 @@ export function PrivateShuttleBookingFlow({
250
111
  initialBooking,
251
112
  onChangeBooking,
252
113
  }: PrivateShuttleBookingFlowProps) {
253
- const { strings: defaultStrings, analytics, catalog } = useBookingHost();
114
+ const { analytics } = useBookingHost();
254
115
  const { t } = useTranslations();
255
116
  const { locale } = useLocale();
256
117
  const companyTimezone = useCompanyTimezone();
@@ -264,11 +125,9 @@ export function PrivateShuttleBookingFlow({
264
125
  suppressCalendarDateScroll,
265
126
  mode: bookingAppMode,
266
127
  } = useBookingApp();
267
- const availabilitiesCache = useAvailabilitiesCache();
268
128
  const isAdmin = permissions.viewerRole === 'admin';
269
129
  const isProviderChangeMode = Boolean(initialBooking && onChangeBooking);
270
130
 
271
- const [availabilities, setAvailabilities] = useState<Availability[]>([]);
272
131
  const [selectedDate, setSelectedDate] = useState<string>('');
273
132
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
274
133
  const [selectedOption, setSelectedOption] = useState<string>('');
@@ -293,9 +152,7 @@ export function PrivateShuttleBookingFlow({
293
152
  const [draftItineraryPlanningNotes, setDraftItineraryPlanningNotes] = useState('');
294
153
  const [childSafetySeatsCount, setChildSafetySeatsCount] = useState(0);
295
154
  const [foodRestrictions, setFoodRestrictions] = useState('');
296
- const [addOnSelections, setAddOnSelections] = useState<
297
- Array<{ addOnId: string; variantId?: string; quantity?: number }>
298
- >(() =>
155
+ const [addOnSelections, setAddOnSelections] = useState<PrivateShuttleAddOnSelection[]>(() =>
299
156
  initialBooking?.addOnSelections?.length
300
157
  ? initialBooking.addOnSelections.map((s) => ({
301
158
  addOnId: s.addOnId,
@@ -306,98 +163,43 @@ export function PrivateShuttleBookingFlow({
306
163
  );
307
164
  const [addOns, setAddOns] = useState<AddOn[]>([]);
308
165
  const [loading, setLoading] = useState(false);
309
- const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
310
- const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
311
166
  const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
312
167
  const [error, setError] = useState('');
313
- const [showCheckoutModal, setShowCheckoutModal] = useState(false);
314
- /** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
315
- const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
316
- /** True while Stripe is confirming payment/redirecting; skip unload cancellation during this window. */
317
- const paymentSubmitInFlightRef = useRef(false);
318
168
  const [termsAccepted, setTermsAccepted] = useState(false);
319
169
  const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
320
170
  const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
321
- const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
322
- const [checkoutModalData, setCheckoutModalData] = useState<{
323
- reservationReference: string;
324
- reservationExpiration?: string;
325
- customerLastName?: string;
326
- bookingDate?: string;
327
- ticketLines: CheckoutModalLineItem[];
328
- feeLineItems: { name: string; totalAmount: number; description?: string }[];
329
- returnPriceAdjustment: number;
330
- subtotal: number;
331
- tax: number;
332
- total: number;
333
- totalQuantity: number;
334
- isTaxIncludedInPrice: boolean;
335
- taxRate: number;
336
- cancellationPolicyFee?: number;
337
- cancellationPolicyLabel?: string;
338
- promoDiscountAmount?: number;
339
- discountLabel?: string | null;
340
- isDepositPayment?: boolean;
341
- balanceChargeDaysBefore?: number;
342
- } | null>(null);
343
171
  const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
344
172
  const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
345
- const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
346
- const [promoCodeInput, setPromoCodeInput] = useState(() => initialBooking?.promoCode?.trim() ?? '');
347
- const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(
348
- () => initialBooking?.promoCode?.trim() || null
349
- );
350
- const [promoCodeError, setPromoCodeError] = useState('');
351
- const [promoCodeValidating, setPromoCodeValidating] = useState(false);
352
- const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
353
- const [isGiftCard, setIsGiftCard] = useState(false);
354
- const [isVoucher, setIsVoucher] = useState(false);
355
- const activePromoCode = isAdmin ? appliedPromoCode : null;
356
173
  const [additionalHoursCount, setAdditionalHoursCount] = useState(
357
174
  () => initialBooking?.additionalHoursCount ?? initialValues?.additionalHoursCount ?? 0
358
175
  );
359
176
  const [isSpecialRequestMode, setIsSpecialRequestMode] = useState(false);
360
177
  const [specialRequestInputValue, setSpecialRequestInputValue] = useState('');
361
- const [adminChoiceData, setAdminChoiceData] = useState<{
362
- reservationReference: string;
363
- reservationExpiration?: string;
364
- checkoutBreakdown: {
365
- lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>;
366
- totalAmount: number;
367
- currency: string;
368
- };
369
- totalAmount: number;
370
- datePart: string;
371
- timePart: string;
372
- availabilityProductOptionId: string;
373
- itineraryDisplay?: ItineraryDisplayStep[] | null;
374
- clientSecret: string;
375
- ticketLinesForModal: CheckoutModalLineItem[];
376
- feeLineItems: { name: string; totalAmount: number; description?: string }[];
377
- cancellationPolicyFee: number;
378
- cancellationPolicyLabel?: string;
379
- subtotal: number;
380
- tax: number;
381
- totalQuantity: number;
382
- isTaxIncludedInPrice: boolean;
383
- taxRate: number;
384
- promoDiscountAmount: number;
385
- discountLabel?: string | null;
386
- } | null>(null);
387
- const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
388
178
  const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(
389
179
  () => initialBooking?.cancellationPolicyId ?? null
390
180
  );
391
- const [precomputedPrices, setPrecomputedPrices] = useState<PrecomputedPricesByCategory | null>(null);
392
- const [resourcePriceByCurrency, setResourcePriceByCurrency] = useState<Record<string, number> | null>(null);
393
- const [resourcePriceByOption, setResourcePriceByOption] = useState<
394
- Record<string, Record<string, number>> | null
395
- >(null);
396
- const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
397
- const lastValidatedInputRef = useRef<string | null>(null);
398
- const fetchingRef = useRef(false);
399
- const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]);
400
- const pricingConfigSetRef = useRef(false);
181
+ const clearFetchedAvailabilityRangesRef = useRef<() => void>(() => {});
182
+ const {
183
+ promoCodeInput,
184
+ appliedPromoCode,
185
+ activePromoCode,
186
+ promoCodeError,
187
+ promoCodeValidating,
188
+ handlePromoInputChange,
189
+ handleApplyPromo,
190
+ handleRemovePromo,
191
+ } = usePrivateShuttlePromoController({
192
+ initialPromoCode: initialBooking?.promoCode,
193
+ isAdmin,
194
+ selectedAvailability,
195
+ selectedOption,
196
+ selectedStartTime,
197
+ resourceCount: Math.ceil(passengerCount / PRIVATE_SHUTTLE_RESOURCE_CAPACITY),
198
+ productCompanyId: product.companyId,
199
+ productId: product.productId,
200
+ onPromoChangesAvailability: () => clearFetchedAvailabilityRangesRef.current(),
201
+ t,
202
+ });
401
203
 
402
204
  useEffect(() => {
403
205
  setPartnerAttributionConfirmed(false);
@@ -416,21 +218,47 @@ export function PrivateShuttleBookingFlow({
416
218
  return () => window.clearInterval(interval);
417
219
  }, [isAdmin]);
418
220
 
419
- // Fire view_item when product is first displayed
420
- const hasFiredViewItem = useRef(false);
421
- useEffect(() => {
422
- if (!hasFiredViewItem.current && product) {
423
- hasFiredViewItem.current = true;
424
- const id = productId || product.productId;
425
- const price = product.minPriceByCurrency?.[currency] ?? 0;
426
- analytics.trackViewItem(id, product.name, price, currency);
427
- }
428
- }, [product, productId, currency]);
221
+ useBookingViewItemAnalytics({ analytics, product, productId, currency });
429
222
 
430
223
  const activeOptionIdsKey = useMemo(
431
224
  () => activeOptions.map((opt) => opt.optionId).sort().join(','),
432
225
  [activeOptions]
433
226
  );
227
+ const {
228
+ availabilities,
229
+ loadingAvailabilities,
230
+ isFetchingMoreAvailabilities,
231
+ pricingConfig,
232
+ precomputedPrices,
233
+ resourcePriceByCurrency,
234
+ resourcePriceByOption,
235
+ availabilitiesByDate,
236
+ optionsAvailableForSelectedDate,
237
+ dates,
238
+ earliestAvailabilityDate,
239
+ handleVisibleRangeChange,
240
+ reloadAvailabilitiesAfterReserveConflict,
241
+ refreshSelectedDateDetailsForCheckout,
242
+ fetchedRangesRef,
243
+ } = usePrivateShuttleAvailability({
244
+ product,
245
+ activeOptions,
246
+ activeOptionIdsKey,
247
+ selectedDate,
248
+ activePromoCode,
249
+ pricingProfileIdForAvailabilities,
250
+ cancellationPolicyProfileIdForAvailabilities,
251
+ companyTimezone,
252
+ isAdmin,
253
+ bookingCutoffNow,
254
+ bookingCutoffMinutes,
255
+ passengerCount,
256
+ setPassengerCount,
257
+ setError,
258
+ });
259
+ clearFetchedAvailabilityRangesRef.current = () => {
260
+ fetchedRangesRef.current = [];
261
+ };
434
262
  const selectedOptionConfig = activeOptions.find((opt) => opt.optionId === selectedOption);
435
263
  const privateShuttleConfig = selectedOptionConfig?.privateShuttleConfig;
436
264
  const selectedPickupLocation = useMemo(
@@ -439,192 +267,6 @@ export function PrivateShuttleBookingFlow({
439
267
  [pickupLocationId, product.pickupLocations]
440
268
  );
441
269
 
442
- const needsFetch = useCallback((start: Date, end: Date) => {
443
- if (fetchedRangesRef.current.length === 0) return true;
444
- return !fetchedRangesRef.current.some(
445
- (range) =>
446
- range.start.getTime() <= start.getTime() && range.end.getTime() >= end.getTime()
447
- );
448
- }, []);
449
-
450
- const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
451
- if (!visibleRange || !product.productId) return [];
452
-
453
- const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
454
- const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
455
- const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
456
- ? startOfEarliestDay
457
- : visibleRange.start;
458
- let clampedEnd = isAfter(visibleRange.end, endOfLatestDay) ? endOfLatestDay : visibleRange.end;
459
- if (selectedDate) {
460
- try {
461
- const selectedDateObj = parseISO(selectedDate);
462
- if (isAfter(selectedDateObj, clampedEnd)) clampedEnd = selectedDateObj;
463
- } catch {
464
- /* ignore */
465
- }
466
- }
467
-
468
- const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
469
- const endDate = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
470
- const result = await getAvailabilities(product.productId, startDate, endDate, {
471
- allOptions: true,
472
- promoCode: activePromoCode || undefined,
473
- ...(pricingProfileIdForAvailabilities
474
- ? { pricingProfileId: pricingProfileIdForAvailabilities }
475
- : {}),
476
- ...(cancellationPolicyProfileIdForAvailabilities
477
- ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
478
- : {}),
479
- });
480
-
481
- if (result.pricingConfig && !pricingConfigSetRef.current) {
482
- setPricingConfig(result.pricingConfig);
483
- pricingConfigSetRef.current = true;
484
- }
485
- if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
486
- if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
487
- if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
488
-
489
- let mergedAvailabilities: Availability[] = [];
490
- setAvailabilities((prev) => {
491
- const existingMap = new Map(prev.map((avail) => [getPrivateShuttleAvailabilityCacheKey(avail), avail]));
492
- result.availabilities.forEach((avail) => {
493
- existingMap.set(getPrivateShuttleAvailabilityCacheKey(avail), avail);
494
- });
495
- mergedAvailabilities = Array.from(existingMap.values());
496
- return mergedAvailabilities;
497
- });
498
-
499
- fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
500
- fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
501
- const mergedRanges: Array<{ start: Date; end: Date }> = [];
502
- for (const r of fetchedRangesRef.current) {
503
- if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].end < r.start) {
504
- mergedRanges.push({ start: r.start, end: r.end });
505
- } else {
506
- mergedRanges[mergedRanges.length - 1].end =
507
- r.end > mergedRanges[mergedRanges.length - 1].end
508
- ? r.end
509
- : mergedRanges[mergedRanges.length - 1].end;
510
- }
511
- }
512
- fetchedRangesRef.current = mergedRanges;
513
-
514
- const cacheKey = availabilitiesCache
515
- ? buildAvailabilitiesCacheKey(
516
- product.productId,
517
- activeOptionIdsKey,
518
- activePromoCode,
519
- pricingProfileIdForAvailabilities,
520
- )
521
- : null;
522
- if (cacheKey && availabilitiesCache) {
523
- availabilitiesCache.merge(cacheKey, {
524
- fetchedRanges: mergedRanges,
525
- availabilities: mergedAvailabilities,
526
- pricingConfig: result.pricingConfig ?? null,
527
- precomputedPrices: result.precomputedPrices ?? null,
528
- resourcePriceByCurrency: result.resourcePriceByCurrency ?? null,
529
- resourcePriceByOption: result.resourcePriceByOption ?? null,
530
- });
531
- }
532
-
533
- return mergedAvailabilities;
534
- }, [
535
- visibleRange,
536
- product.productId,
537
- selectedDate,
538
- companyTimezone,
539
- activePromoCode,
540
- pricingProfileIdForAvailabilities,
541
- cancellationPolicyProfileIdForAvailabilities,
542
- activeOptionIdsKey,
543
- availabilitiesCache,
544
- ]);
545
-
546
- /** Fresh selected-day check before reserve so stale private shuttle vacancies are corrected before payment. */
547
- const refreshSelectedDateDetailsForCheckout = useCallback(async (): Promise<Availability[]> => {
548
- if (!selectedDate || !product.productId) return [];
549
-
550
- const result = await getAvailabilities(product.productId, selectedDate, selectedDate, {
551
- allOptions: true,
552
- promoCode: activePromoCode || undefined,
553
- ...(pricingProfileIdForAvailabilities
554
- ? { pricingProfileId: pricingProfileIdForAvailabilities }
555
- : {}),
556
- ...(cancellationPolicyProfileIdForAvailabilities
557
- ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
558
- : {}),
559
- });
560
-
561
- if (result.pricingConfig && !pricingConfigSetRef.current) {
562
- setPricingConfig(result.pricingConfig);
563
- pricingConfigSetRef.current = true;
564
- }
565
- if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
566
- if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
567
- if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
568
-
569
- let mergedAvailabilities: Availability[] = [];
570
- setAvailabilities((prev) => {
571
- const existingMap = new Map(prev.map((avail) => [getPrivateShuttleAvailabilityCacheKey(avail), avail]));
572
- result.availabilities.forEach((avail) => {
573
- existingMap.set(getPrivateShuttleAvailabilityCacheKey(avail), avail);
574
- });
575
- mergedAvailabilities = Array.from(existingMap.values());
576
- return mergedAvailabilities;
577
- });
578
-
579
- const cacheKey = availabilitiesCache
580
- ? buildAvailabilitiesCacheKey(
581
- product.productId,
582
- activeOptionIdsKey,
583
- activePromoCode,
584
- pricingProfileIdForAvailabilities,
585
- )
586
- : null;
587
- if (cacheKey && availabilitiesCache) {
588
- const existingCache = availabilitiesCache.get(cacheKey);
589
- const mergedAvailabilitiesMap = new Map(
590
- (existingCache?.availabilities ?? []).map((availability) => [
591
- getPrivateShuttleAvailabilityCacheKey(availability),
592
- availability,
593
- ])
594
- );
595
- result.availabilities.forEach((availability) => {
596
- mergedAvailabilitiesMap.set(getPrivateShuttleAvailabilityCacheKey(availability), availability);
597
- });
598
- availabilitiesCache.merge(cacheKey, {
599
- fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
600
- availabilities: Array.from(mergedAvailabilitiesMap.values()),
601
- pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
602
- precomputedPrices: result.precomputedPrices ?? existingCache?.precomputedPrices ?? null,
603
- resourcePriceByCurrency: result.resourcePriceByCurrency ?? existingCache?.resourcePriceByCurrency ?? null,
604
- resourcePriceByOption: result.resourcePriceByOption ?? existingCache?.resourcePriceByOption ?? null,
605
- });
606
- }
607
-
608
- return result.availabilities;
609
- }, [
610
- selectedDate,
611
- product.productId,
612
- activePromoCode,
613
- pricingProfileIdForAvailabilities,
614
- cancellationPolicyProfileIdForAvailabilities,
615
- availabilitiesCache,
616
- activeOptionIdsKey,
617
- ]);
618
-
619
- useEffect(() => {
620
- if (!visibleRange) {
621
- setVisibleRange({
622
- start: EARLIEST_AVAILABILITY_DATE,
623
- end: addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS + VISIBLE_RANGE_BUFFER_WEEKS),
624
- });
625
- }
626
- }, [visibleRange]);
627
-
628
270
  useEffect(() => {
629
271
  if (selectedOption && !product.companyId) return;
630
272
  if (!selectedOption) {
@@ -645,369 +287,6 @@ export function PrivateShuttleBookingFlow({
645
287
  }
646
288
  }, [draftItineraryDestinations]);
647
289
 
648
- useEffect(() => {
649
- fetchedRangesRef.current = [];
650
- }, [
651
- activePromoCode,
652
- pricingProfileIdForAvailabilities,
653
- cancellationPolicyProfileIdForAvailabilities,
654
- ]);
655
-
656
- // Memoized callback for visible range changes - only update if range changed significantly (match BookingFlow)
657
- const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
658
- const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
659
- const lastRange = lastVisibleRangeRef.current;
660
- const rangeChanged =
661
- !lastRange ||
662
- Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
663
- Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
664
- if (rangeChanged) {
665
- lastVisibleRangeRef.current = { start, end };
666
- setVisibleRange({ start, end });
667
- }
668
- }, []);
669
-
670
- useEffect(() => {
671
- if (activeOptions.length === 0) {
672
- setError('No active product options available');
673
- setLoadingAvailabilities(false);
674
- return;
675
- }
676
- if (!visibleRange) return;
677
-
678
- async function fetchAvailabilities() {
679
- if (fetchingRef.current) return;
680
- const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
681
- const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
682
- const clampedStart = isBefore(visibleRange!.start, startOfEarliestDay)
683
- ? startOfEarliestDay
684
- : visibleRange!.start;
685
- let clampedEnd = isAfter(visibleRange!.end, endOfLatestDay)
686
- ? endOfLatestDay
687
- : visibleRange!.end;
688
- if (selectedDate) {
689
- try {
690
- const selectedDateObj = parseISO(selectedDate);
691
- if (isAfter(selectedDateObj, clampedEnd)) clampedEnd = selectedDateObj;
692
- } catch {
693
- /* ignore */
694
- }
695
- }
696
- // Check cache first - avoid refetch when reopening same product (match BookingFlow)
697
- const cacheKey = availabilitiesCache
698
- ? buildAvailabilitiesCacheKey(
699
- product.productId,
700
- activeOptionIdsKey,
701
- activePromoCode,
702
- pricingProfileIdForAvailabilities,
703
- )
704
- : null;
705
- const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
706
- let shouldRevalidateCachedRange = false;
707
- if (cached && cached.availabilities.length > 0) {
708
- const cacheCoversRange = cached.fetchedRanges.some(
709
- (r) =>
710
- r.start.getTime() <= clampedStart.getTime() && r.end.getTime() >= clampedEnd.getTime()
711
- );
712
- const isStale = availabilitiesCache?.isStale(cached) ?? false;
713
- if (cacheCoversRange) {
714
- shouldRevalidateCachedRange = true;
715
- setAvailabilities(cached.availabilities);
716
- if (cached.pricingConfig) {
717
- setPricingConfig(cached.pricingConfig);
718
- pricingConfigSetRef.current = true;
719
- }
720
- if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
721
- if (cached.resourcePriceByCurrency) setResourcePriceByCurrency(cached.resourcePriceByCurrency);
722
- if (cached.resourcePriceByOption) setResourcePriceByOption(cached.resourcePriceByOption);
723
- setLoadingAvailabilities(false);
724
- setIsFetchingMoreAvailabilities(false);
725
- if (!isStale) {
726
- fetchedRangesRef.current = [...cached.fetchedRanges];
727
- // Show cached availability instantly, then quietly revalidate the visible range.
728
- }
729
- }
730
- // Partial cache: show cached data immediately, then fetch missing range below
731
- setAvailabilities(cached.availabilities);
732
- if (cached.pricingConfig) {
733
- setPricingConfig(cached.pricingConfig);
734
- pricingConfigSetRef.current = true;
735
- }
736
- if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
737
- if (cached.resourcePriceByCurrency) setResourcePriceByCurrency(cached.resourcePriceByCurrency);
738
- if (cached.resourcePriceByOption) setResourcePriceByOption(cached.resourcePriceByOption);
739
- fetchedRangesRef.current = [...cached.fetchedRanges];
740
- setLoadingAvailabilities(false);
741
- }
742
-
743
- if (!shouldRevalidateCachedRange && !needsFetch(clampedStart, clampedEnd)) {
744
- setLoadingAvailabilities(false);
745
- setIsFetchingMoreAvailabilities(false);
746
- fetchingRef.current = false;
747
- return;
748
- }
749
- const hasPartialCache = cached && cached.availabilities.length > 0;
750
- fetchingRef.current = true;
751
- if (!hasPartialCache) setLoadingAvailabilities(true);
752
- else if (!shouldRevalidateCachedRange) setIsFetchingMoreAvailabilities(true);
753
- try {
754
- const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
755
- const endDate = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
756
- const result = await getAvailabilities(product.productId, startDate, endDate, {
757
- allOptions: true,
758
- summary: true,
759
- promoCode: activePromoCode || undefined,
760
- ...(pricingProfileIdForAvailabilities
761
- ? { pricingProfileId: pricingProfileIdForAvailabilities }
762
- : {}),
763
- ...(cancellationPolicyProfileIdForAvailabilities
764
- ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
765
- : {}),
766
- });
767
- if (result.pricingConfig && !pricingConfigSetRef.current) {
768
- setPricingConfig(result.pricingConfig);
769
- pricingConfigSetRef.current = true;
770
- }
771
- if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
772
- if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
773
- if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
774
- // Merge with existing when we had partial cache (match BookingFlow)
775
- const mergedAvailabilities = (() => {
776
- const existing = hasPartialCache && cached ? cached.availabilities : [];
777
- if (existing.length === 0) return result.availabilities;
778
- const existingMap = new Map(
779
- existing.map((avail) => [
780
- getPrivateShuttleAvailabilityCacheKey(avail),
781
- avail,
782
- ])
783
- );
784
- result.availabilities.forEach((avail) => {
785
- existingMap.set(getPrivateShuttleAvailabilityCacheKey(avail), avail);
786
- });
787
- return Array.from(existingMap.values());
788
- })();
789
- setAvailabilities(mergedAvailabilities);
790
- fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
791
- fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
792
- const merged: Array<{ start: Date; end: Date }> = [];
793
- for (const r of fetchedRangesRef.current) {
794
- if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
795
- merged.push({ start: r.start, end: r.end });
796
- } else {
797
- merged[merged.length - 1].end =
798
- r.end > merged[merged.length - 1].end ? r.end : merged[merged.length - 1].end;
799
- }
800
- }
801
- fetchedRangesRef.current = merged;
802
- if (passengerCount === 0) setPassengerCount(1);
803
-
804
- // Update cache for instant load when reopening same product (match BookingFlow)
805
- if (cacheKey && availabilitiesCache) {
806
- availabilitiesCache.merge(cacheKey, {
807
- fetchedRanges: fetchedRangesRef.current,
808
- availabilities: mergedAvailabilities,
809
- pricingConfig: result.pricingConfig ?? null,
810
- precomputedPrices: result.precomputedPrices ?? null,
811
- resourcePriceByCurrency: result.resourcePriceByCurrency ?? null,
812
- resourcePriceByOption: result.resourcePriceByOption ?? null,
813
- });
814
- }
815
- } catch (err) {
816
- if (shouldRevalidateCachedRange && cached?.availabilities.length) {
817
- console.warn('Background private shuttle availability refresh failed; keeping cached availability visible.', err);
818
- } else {
819
- setError(err instanceof Error ? err.message : 'Failed to load availabilities');
820
- }
821
- } finally {
822
- setLoadingAvailabilities(false);
823
- setIsFetchingMoreAvailabilities(false);
824
- fetchingRef.current = false;
825
- }
826
- }
827
- fetchAvailabilities();
828
- // passengerCount intentionally excluded - we only use it for init (setPassengerCount(1)), not for fetch
829
- // eslint-disable-next-line react-hooks/exhaustive-deps
830
- }, [
831
- visibleRange,
832
- activeOptions,
833
- activeOptionIdsKey,
834
- selectedDate,
835
- product.productId,
836
- activePromoCode,
837
- pricingProfileIdForAvailabilities,
838
- cancellationPolicyProfileIdForAvailabilities,
839
- needsFetch,
840
- availabilitiesCache,
841
- ]);
842
-
843
- const isPrivateShuttleAvailabilityBookable = useCallback(
844
- (availability: Availability): boolean => {
845
- if (isAdmin) return true;
846
- const dateInCompanyTz = DATE_ONLY_REGEX.test(availability.dateTime)
847
- ? availability.dateTime
848
- : formatInTimeZone(parseAvailabilityDateTime(availability.dateTime), companyTimezone, 'yyyy-MM-dd');
849
- const optionId = getPrivateShuttleAvailabilityOptionId(availability);
850
- const option = activeOptions.find((opt) => opt.optionId === optionId);
851
- const startTimes = availability.suggestedStartTimes || option?.privateShuttleConfig?.suggestedStartTimes || [];
852
- if (startTimes.length === 0) {
853
- return isDateAfterPublicBookingCutoff(
854
- fromZonedTime(parseISO(`${dateInCompanyTz}T23:59:59`), companyTimezone),
855
- bookingCutoffNow,
856
- bookingCutoffMinutes,
857
- );
858
- }
859
- return startTimes.some((time) =>
860
- privateShuttleStartTimeAllowed(
861
- dateInCompanyTz,
862
- time,
863
- companyTimezone,
864
- bookingCutoffNow,
865
- bookingCutoffMinutes,
866
- ),
867
- );
868
- },
869
- [activeOptions, bookingCutoffMinutes, bookingCutoffNow, companyTimezone, isAdmin],
870
- );
871
-
872
- const publicVisibleAvailabilities = useMemo(
873
- () => (isAdmin ? availabilities : availabilities.filter(isPrivateShuttleAvailabilityBookable)),
874
- [availabilities, isAdmin, isPrivateShuttleAvailabilityBookable],
875
- );
876
-
877
- // Group availabilities by date (in company timezone - MDT). Availabilities from API are in UTC.
878
- const availabilitiesByDate = useMemo(() => {
879
- const grouped = publicVisibleAvailabilities.reduce(
880
- (acc, avail) => {
881
- const dateInCompanyTz = DATE_ONLY_REGEX.test(avail.dateTime)
882
- ? avail.dateTime
883
- : formatInTimeZone(parseAvailabilityDateTime(avail.dateTime), companyTimezone, 'yyyy-MM-dd');
884
- if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
885
- acc[dateInCompanyTz].push(avail);
886
- return acc;
887
- },
888
- {} as Record<string, Availability[]>
889
- );
890
- // Sort availabilities within each date by time (match BookingFlow)
891
- const sorted: Record<string, Availability[]> = {};
892
- Object.keys(grouped).forEach((date) => {
893
- sorted[date] = [...grouped[date]].sort((a, b) => {
894
- const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
895
- const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
896
- return timeA - timeB;
897
- });
898
- });
899
- return sorted;
900
- }, [publicVisibleAvailabilities, companyTimezone]);
901
-
902
- const optionsAvailableForSelectedDate = useMemo(() => {
903
- if (!selectedDate) return [];
904
- const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
905
- const optionIds = new Set(dateAvailabilities.map(getPrivateShuttleAvailabilityOptionId).filter(Boolean));
906
- return activeOptions.filter((opt) => optionIds.has(opt.optionId));
907
- }, [selectedDate, availabilitiesByDate, activeOptions]);
908
-
909
- const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
910
-
911
- useEffect(() => {
912
- if (!selectedDate || !product.productId) return;
913
- const selectedDateAvailabilities = availabilitiesByDate[selectedDate] ?? [];
914
- if (!selectedDateAvailabilities.some((availability) => availability.isSummary)) return;
915
-
916
- let cancelled = false;
917
- async function hydrateSelectedDateDetails() {
918
- setIsFetchingMoreAvailabilities(true);
919
- try {
920
- const result = await getAvailabilities(product.productId, selectedDate, selectedDate, {
921
- allOptions: true,
922
- promoCode: activePromoCode || undefined,
923
- ...(pricingProfileIdForAvailabilities
924
- ? { pricingProfileId: pricingProfileIdForAvailabilities }
925
- : {}),
926
- ...(cancellationPolicyProfileIdForAvailabilities
927
- ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
928
- : {}),
929
- });
930
- if (cancelled) return;
931
-
932
- if (result.pricingConfig && !pricingConfigSetRef.current) {
933
- setPricingConfig(result.pricingConfig);
934
- pricingConfigSetRef.current = true;
935
- }
936
- if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
937
- if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
938
- if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
939
-
940
- setAvailabilities((prev) => {
941
- const merged = new Map(
942
- prev.map((availability) => [
943
- getPrivateShuttleAvailabilityCacheKey(availability),
944
- availability,
945
- ])
946
- );
947
- result.availabilities.forEach((availability) => {
948
- merged.set(getPrivateShuttleAvailabilityCacheKey(availability), availability);
949
- });
950
- return Array.from(merged.values());
951
- });
952
-
953
- const cacheKey = availabilitiesCache
954
- ? buildAvailabilitiesCacheKey(
955
- product.productId,
956
- activeOptionIdsKey,
957
- activePromoCode,
958
- pricingProfileIdForAvailabilities,
959
- )
960
- : null;
961
- if (cacheKey && availabilitiesCache) {
962
- const existingCache = availabilitiesCache.get(cacheKey);
963
- const mergedAvailabilities = new Map(
964
- (existingCache?.availabilities ?? []).map((availability) => [
965
- getPrivateShuttleAvailabilityCacheKey(availability),
966
- availability,
967
- ])
968
- );
969
- result.availabilities.forEach((availability) => {
970
- mergedAvailabilities.set(getPrivateShuttleAvailabilityCacheKey(availability), availability);
971
- });
972
- availabilitiesCache.merge(cacheKey, {
973
- fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
974
- availabilities: Array.from(mergedAvailabilities.values()),
975
- pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
976
- precomputedPrices: result.precomputedPrices ?? existingCache?.precomputedPrices ?? null,
977
- resourcePriceByCurrency: result.resourcePriceByCurrency ?? existingCache?.resourcePriceByCurrency ?? null,
978
- resourcePriceByOption: result.resourcePriceByOption ?? existingCache?.resourcePriceByOption ?? null,
979
- });
980
- }
981
- } catch (err) {
982
- if (!cancelled) {
983
- console.error('Error hydrating private shuttle availability details:', err);
984
- }
985
- } finally {
986
- if (!cancelled) setIsFetchingMoreAvailabilities(false);
987
- }
988
- }
989
-
990
- hydrateSelectedDateDetails();
991
- return () => {
992
- cancelled = true;
993
- };
994
- }, [
995
- selectedDate,
996
- availabilitiesByDate,
997
- product.productId,
998
- activeOptionIdsKey,
999
- activePromoCode,
1000
- pricingProfileIdForAvailabilities,
1001
- cancellationPolicyProfileIdForAvailabilities,
1002
- availabilitiesCache,
1003
- ]);
1004
-
1005
- const earliestAvailabilityDate = useMemo(() => {
1006
- if (dates.length === 0) return EARLIEST_AVAILABILITY_DATE;
1007
- // Build date in company timezone at noon to avoid cross-timezone day shifts.
1008
- return fromZonedTime(parseISO(`${dates[0]}T12:00:00`), companyTimezone);
1009
- }, [dates, companyTimezone]);
1010
-
1011
290
  const suggestedStartTimes = useMemo(() => {
1012
291
  const times = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
1013
292
  if (isAdmin || !selectedDate) return times;
@@ -1029,263 +308,57 @@ export function PrivateShuttleBookingFlow({
1029
308
  selectedAvailability?.suggestedStartTimes,
1030
309
  selectedDate,
1031
310
  ]);
1032
- const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
1033
- const maxVacancies = selectedAvailability?.vacancies || 0;
1034
- const isSpecialRequest = passengerCount > maxVacancies;
1035
- const billableResourceCount = isSpecialRequest
1036
- ? Math.max(1, Math.ceil(maxVacancies / RESOURCE_CAPACITY))
1037
- : resourceCount;
1038
-
1039
- const getOptionPrice = useCallback(
1040
- (optionId: string) => {
1041
- const opt = activeOptions.find((o) => o.optionId === optionId);
1042
- const dateAvailabilities = selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
1043
- const avail = dateAvailabilities.find(
1044
- (a) => getPrivateShuttleAvailabilityOptionId(a) === optionId
1045
- );
1046
- const profileOptionPrice = resourcePriceByOption?.[optionId]?.[currency];
1047
- if (profileOptionPrice != null) {
1048
- return applyResourceAdjustments(profileOptionPrice, avail, currency);
1049
- }
1050
- if (currency === 'CAD' && opt?.pricing?.['RESOURCE'] != null) {
1051
- return applyResourceAdjustments(opt.pricing['RESOURCE'], avail, currency);
1052
- }
1053
- if (avail?.rates) {
1054
- const resourceRate = getResourceRate(avail);
1055
- if (resourceRate?.priceByCurrency?.[currency] != null)
1056
- return resourceRate.priceByCurrency[currency];
1057
- if (currency === 'CAD' && resourceRate?.price != null) return resourceRate.price;
1058
- }
1059
- if (currency === 'CAD' && resourcePriceByCurrency?.[currency] != null) return resourcePriceByCurrency[currency];
1060
- return opt?.pricing?.['RESOURCE'] ?? 0;
1061
- },
1062
- [
1063
- selectedDate,
1064
- availabilitiesByDate,
1065
- resourcePriceByOption,
1066
- resourcePriceByCurrency,
1067
- currency,
1068
- activeOptions,
1069
- ]
1070
- );
1071
-
1072
- const resourcePrice = useMemo(() => {
1073
- const selectedProfileOptionPrice = selectedOption
1074
- ? resourcePriceByOption?.[selectedOption]?.[currency]
1075
- : undefined;
1076
- if (selectedProfileOptionPrice != null) {
1077
- return applyResourceAdjustments(selectedProfileOptionPrice, selectedAvailability, currency);
1078
- }
1079
- if (currency === 'CAD' && selectedOptionConfig?.pricing?.['RESOURCE'] != null)
1080
- return applyResourceAdjustments(
1081
- selectedOptionConfig.pricing['RESOURCE'],
1082
- selectedAvailability,
1083
- currency
1084
- );
1085
- if (selectedAvailability?.rates) {
1086
- const resourceRate = getResourceRate(selectedAvailability);
1087
- if (resourceRate) {
1088
- const fromRate =
1089
- resourceRate.priceByCurrency?.[currency] ??
1090
- (currency === 'CAD' ? resourceRate.price : undefined);
1091
- if (fromRate != null) return fromRate;
1092
- }
1093
- }
1094
- if (currency === 'CAD' && resourcePriceByCurrency?.[currency] != null) return resourcePriceByCurrency[currency];
1095
- if (precomputedPrices?.['RESOURCE']?.[currency] != null)
1096
- return precomputedPrices['RESOURCE'][currency];
1097
- if (selectedOptionConfig?.pricing?.['RESOURCE'] != null)
1098
- return selectedOptionConfig.pricing['RESOURCE'];
1099
- return 0;
1100
- }, [
311
+ const {
312
+ resourceCount,
313
+ maxVacancies,
314
+ isSpecialRequest,
315
+ billableResourceCount,
316
+ getOptionPrice,
317
+ resourcePrice,
318
+ basePrice,
319
+ isTaxIncludedInPrice,
320
+ taxRate,
321
+ selectedCancellationPolicy,
322
+ cancellationPolicyFee,
323
+ additionalHoursAmount,
324
+ perBookingFeeLineItems,
325
+ subtotal,
326
+ promoDiscountAmount,
327
+ isGiftCard,
328
+ effectivePromoDiscountAmount,
329
+ effectiveTaxAmount,
330
+ totalPrice,
331
+ providerChangeAuthoritativeReceipt,
332
+ depositInfo,
333
+ priceSummaryLines,
334
+ resetPromoDiscount,
335
+ } = usePrivateShuttlePriceSummary({
336
+ selectedOption,
337
+ selectedDate,
338
+ selectedStartTime,
1101
339
  selectedAvailability,
340
+ selectedOptionConfig,
341
+ activeOptions,
342
+ availabilitiesByDate,
1102
343
  resourcePriceByOption,
1103
344
  resourcePriceByCurrency,
1104
345
  precomputedPrices,
1105
- selectedOption,
1106
- selectedOptionConfig,
346
+ passengerCount,
1107
347
  currency,
1108
- ]);
1109
-
1110
- const basePrice = resourcePrice * resourceCount;
1111
- const isTaxIncludedInPrice = (pricingConfig?.currenciesWithTaxIncluded ?? []).includes(currency);
1112
- const taxRate = pricingConfig?.taxRate ?? 0.05;
1113
- const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find(
1114
- (p) => p.id === cancellationPolicyId
1115
- );
1116
- const cancellationPolicyFee = selectedCancellationPolicy
1117
- ? (selectedCancellationPolicy.feeByCurrency[currency] ?? 0)
1118
- : 0;
1119
- const draftItineraryIncludesMoraineLake = useMemo(() => {
1120
- if (draftItineraryDestinations.length === 0) return false;
1121
- const destinationLabelsById = new Map(
1122
- (product.itineraryBuilder?.destinations ?? []).map((destination) => [
1123
- destination.id,
1124
- destination.label,
1125
- ])
1126
- );
1127
- return draftItineraryDestinations.some((destinationId) => {
1128
- const label = destinationLabelsById.get(destinationId);
1129
- return (
1130
- isMoraineLakeLocationText(destinationId) ||
1131
- (label ? isMoraineLakeLocationText(label) : false)
1132
- );
1133
- });
1134
- }, [draftItineraryDestinations, product.itineraryBuilder?.destinations]);
1135
- const perBookingFeeLineItems = useMemo(() => {
1136
- if (isTaxIncludedInPrice) return [];
1137
- const amountsByName = pricingConfig?.perBookingFeesByCurrency?.[currency];
1138
- if (!amountsByName) return [];
1139
- const feeMetadata = pricingConfig?.perBookingFees ?? {};
1140
- return Object.entries(amountsByName)
1141
- .filter(
1142
- ([name]) =>
1143
- draftItineraryIncludesMoraineLake || !isMoraineLakeRoadAccessFeeName(name)
1144
- )
1145
- .map(([name, amount]) => ({
1146
- name,
1147
- totalAmount: amount,
1148
- description: feeMetadata[name]?.description,
1149
- }));
1150
- }, [currency, draftItineraryIncludesMoraineLake, isTaxIncludedInPrice, pricingConfig]);
1151
-
1152
- const addOnTotal = useMemo(() => {
1153
- let sum = 0;
1154
- for (const sel of addOnSelections) {
1155
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1156
- if (!addOn) continue;
1157
- const base = addOn.price ?? 0;
1158
- const hasVariant =
1159
- (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
1160
- sel.variantId;
1161
- const adj = hasVariant
1162
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1163
- : 0;
1164
- sum += (base + adj) * (sel.quantity ?? 1);
1165
- }
1166
- return sum;
1167
- }, [addOnSelections, addOns]);
1168
-
1169
- const additionalHoursAmount = (isAdmin ? additionalHoursCount : 0) * ADDITIONAL_HOUR_PRICE;
1170
- const perBookingFeeTotal = perBookingFeeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0);
1171
- const subtotal = basePrice + addOnTotal + additionalHoursAmount + perBookingFeeTotal;
1172
- const effectivePromoDiscountAmount = promoDiscountAmount > 0 ? promoDiscountAmount : 0;
1173
- const taxAmount = isTaxIncludedInPrice ? 0 : subtotal * taxRate;
1174
- const effectiveTaxAmount =
1175
- effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
1176
- ? (isTaxIncludedInPrice ? 0 : (subtotal - effectivePromoDiscountAmount) * taxRate)
1177
- : taxAmount;
1178
- const totalPrice = subtotal + effectiveTaxAmount - effectivePromoDiscountAmount;
1179
- const providerChangeAuthoritativeReceipt = useMemo<
1180
- ProviderDashboardChangeBookingPayload['authoritativeReceipt']
1181
- >(() => {
1182
- const lineItems: NonNullable<
1183
- ProviderDashboardChangeBookingPayload['authoritativeReceipt']
1184
- >['lineItems'] = [
1185
- {
1186
- type: 'TICKET',
1187
- label: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
1188
- amount: roundMoney(basePrice),
1189
- quantity: resourceCount,
1190
- },
1191
- ];
1192
-
1193
- for (const sel of addOnSelections) {
1194
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1195
- if (!addOn) continue;
1196
- const hasVariant =
1197
- (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
1198
- sel.variantId;
1199
- const variant = hasVariant
1200
- ? addOn.variants?.find((v) => v.id === sel.variantId)
1201
- : null;
1202
- const amount = ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * (sel.quantity ?? 1);
1203
- lineItems.push({
1204
- type: 'FEE',
1205
- label: variant?.label ? `${addOn.name} (${variant.label})` : addOn.name,
1206
- amount: roundMoney(amount),
1207
- });
1208
- }
1209
-
1210
- if (additionalHoursAmount > 0) {
1211
- lineItems.push({
1212
- type: 'FEE',
1213
- label:
1214
- additionalHoursCount === 1
1215
- ? 'Additional hour'
1216
- : `Additional hours (${additionalHoursCount})`,
1217
- amount: roundMoney(additionalHoursAmount),
1218
- });
1219
- }
1220
-
1221
- for (const fee of perBookingFeeLineItems) {
1222
- lineItems.push({
1223
- type: 'FEE',
1224
- label: fee.name,
1225
- amount: roundMoney(fee.totalAmount),
1226
- });
1227
- }
1228
-
1229
- if (cancellationPolicyFee > 0 && selectedCancellationPolicy) {
1230
- lineItems.push({
1231
- type: 'CANCELLATION_UPGRADE',
1232
- label: selectedCancellationPolicy.label,
1233
- amount: roundMoney(cancellationPolicyFee),
1234
- });
1235
- }
1236
-
1237
- if (effectiveTaxAmount > 0) {
1238
- lineItems.push({
1239
- type: 'TAX',
1240
- label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
1241
- amount: roundMoney(effectiveTaxAmount),
1242
- });
1243
- }
1244
-
1245
- if (effectivePromoDiscountAmount > 0) {
1246
- lineItems.push({
1247
- type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
1248
- label: activePromoCode ? `Promo: ${activePromoCode}` : (t('booking.discount') || 'Discount'),
1249
- amount: roundMoney(-effectivePromoDiscountAmount),
1250
- });
1251
- }
1252
-
1253
- const expectedTotal = roundMoney(totalPrice);
1254
- const lineTotal = roundMoney(lineItems.reduce((sum, line) => sum + line.amount, 0));
1255
- const roundingDelta = roundMoney(expectedTotal - lineTotal);
1256
- if (Math.abs(roundingDelta) >= 0.01) {
1257
- lineItems.push({
1258
- type: 'ROUNDING',
1259
- label: t('booking.rounding') || 'Rounding',
1260
- amount: roundingDelta,
1261
- });
1262
- }
1263
-
1264
- return {
1265
- currency,
1266
- lineItems,
1267
- subtotalBeforeTax: roundMoney(expectedTotal - roundMoney(effectiveTaxAmount)),
1268
- taxAmount: roundMoney(effectiveTaxAmount),
1269
- totalAmount: expectedTotal,
1270
- };
1271
- }, [
1272
- activePromoCode,
348
+ pricingConfig,
349
+ cancellationPolicyId,
350
+ draftItineraryDestinations,
351
+ itineraryDestinations: product.itineraryBuilder?.destinations,
1273
352
  addOnSelections,
1274
353
  addOns,
1275
- additionalHoursAmount,
354
+ isAdmin,
1276
355
  additionalHoursCount,
1277
- basePrice,
1278
- cancellationPolicyFee,
1279
- currency,
1280
- effectivePromoDiscountAmount,
1281
- effectiveTaxAmount,
1282
- isGiftCard,
1283
- perBookingFeeLineItems,
1284
- resourceCount,
1285
- selectedCancellationPolicy,
356
+ activePromoCode,
357
+ productCompanyId: product.companyId,
358
+ productId: product.productId,
359
+ depositConfig: privateShuttleConfig?.depositConfig,
1286
360
  t,
1287
- totalPrice,
1288
- ]);
361
+ });
1289
362
 
1290
363
  useEffect(() => {
1291
364
  if (!onPricePreviewChange) return;
@@ -1310,90 +383,6 @@ export function PrivateShuttleBookingFlow({
1310
383
  currency,
1311
384
  ]);
1312
385
 
1313
- const depositInfo = useMemo(() => {
1314
- if (!privateShuttleConfig?.depositConfig || totalPrice <= 0) return null;
1315
- const { depositConfig } = privateShuttleConfig;
1316
- let depositAmount = 0;
1317
- if (depositConfig.percentage != null) {
1318
- // Support both: 30 = 30%, or 0.3 = 30% (decimal from API)
1319
- const pct = depositConfig.percentage;
1320
- depositAmount = pct <= 1 ? totalPrice * pct : (totalPrice * pct) / 100;
1321
- } else if (depositConfig.fixedAmount != null) {
1322
- depositAmount = depositConfig.fixedAmount;
1323
- }
1324
- if (depositAmount <= 0) return null;
1325
- return {
1326
- depositAmount,
1327
- balanceAmount: totalPrice - depositAmount,
1328
- totalPrice,
1329
- };
1330
- }, [privateShuttleConfig, totalPrice]);
1331
-
1332
- const hasOngoingDiscount = useMemo(
1333
- () =>
1334
- selectedAvailability?.rates?.some((r) =>
1335
- (r.appliedAdjustments ?? r.applied_adjustments ?? []).some(
1336
- (a) => (a.type ?? '').toLowerCase() === 'deal'
1337
- )
1338
- ) ?? false,
1339
- [selectedAvailability]
1340
- );
1341
-
1342
- useEffect(() => {
1343
- if (
1344
- !activePromoCode ||
1345
- !selectedOption ||
1346
- !selectedDate ||
1347
- !selectedStartTime ||
1348
- resourceCount === 0
1349
- ) {
1350
- setPromoDiscountAmount(0);
1351
- setIsGiftCard(false);
1352
- setIsVoucher(false);
1353
- return;
1354
- }
1355
- const companyId = product.companyId;
1356
- if (!companyId) return;
1357
- let cancelled = false;
1358
- getPromoDiscount(
1359
- activePromoCode,
1360
- companyId,
1361
- product.productId,
1362
- selectedOption,
1363
- currency,
1364
- [{ category: 'RESOURCE', qty: resourceCount }],
1365
- selectedDate,
1366
- subtotal
1367
- )
1368
- .then((res) => {
1369
- if (!cancelled) {
1370
- setPromoDiscountAmount(res.discount ?? 0);
1371
- setIsGiftCard(res.isGiftCard ?? false);
1372
- setIsVoucher(res.isVoucher ?? false);
1373
- }
1374
- })
1375
- .catch(() => {
1376
- if (!cancelled) {
1377
- setPromoDiscountAmount(0);
1378
- setIsGiftCard(false);
1379
- setIsVoucher(false);
1380
- }
1381
- });
1382
- return () => {
1383
- cancelled = true;
1384
- };
1385
- }, [
1386
- activePromoCode,
1387
- selectedOption,
1388
- selectedDate,
1389
- selectedStartTime,
1390
- resourceCount,
1391
- product.companyId,
1392
- product.productId,
1393
- currency,
1394
- subtotal,
1395
- ]);
1396
-
1397
386
  const handleDateSelect = (date: string) => {
1398
387
  setSelectedDate(date);
1399
388
  setSelectedOption('');
@@ -1576,71 +565,15 @@ export function PrivateShuttleBookingFlow({
1576
565
  setError('');
1577
566
  };
1578
567
 
1579
- const handleApplyPromo = useCallback(async () => {
1580
- const code = promoCodeInput.trim().toUpperCase();
1581
- if (!isAdmin) return;
1582
- if (!code || appliedPromoCode === code) return;
1583
- if (!product.companyId) return;
1584
- lastValidatedInputRef.current = code;
1585
- setPromoCodeError('');
1586
- setPromoCodeValidating(true);
1587
- try {
1588
- const result = await validatePromoCode(
1589
- code,
1590
- product.companyId,
1591
- product.productId,
1592
- hasOngoingDiscount
1593
- );
1594
- if (result.valid) {
1595
- setAppliedPromoCode(code);
1596
- fetchedRangesRef.current = [];
1597
- } else {
1598
- setPromoCodeError(
1599
- result.error === 'Promo codes cannot be stacked with deals'
1600
- ? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
1601
- : (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code')
1602
- );
1603
- }
1604
- } catch (err) {
1605
- setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
1606
- } finally {
1607
- setPromoCodeValidating(false);
1608
- }
1609
- }, [
1610
- promoCodeInput,
1611
- appliedPromoCode,
1612
- isAdmin,
1613
- product.companyId,
1614
- product.productId,
1615
- hasOngoingDiscount,
1616
- t,
1617
- ]);
1618
-
1619
- const handleApplyPromoRef = useRef(handleApplyPromo);
1620
- handleApplyPromoRef.current = handleApplyPromo;
1621
-
1622
- useEffect(() => {
1623
- if (!isAdmin) return;
1624
- const trimmed = promoCodeInput.trim().toUpperCase();
1625
- if (!trimmed) return;
1626
- if (!selectedOption || !selectedStartTime || resourceCount === 0) return;
1627
- if (appliedPromoCode === trimmed) return;
1628
- if (lastValidatedInputRef.current === trimmed && promoCodeError) return;
1629
-
1630
- const timer = setTimeout(() => {
1631
- handleApplyPromoRef.current();
1632
- }, 600);
568
+ const handleRemovePromoAndDiscount = useCallback(() => {
569
+ handleRemovePromo();
570
+ resetPromoDiscount();
571
+ }, [handleRemovePromo, resetPromoDiscount]);
1633
572
 
1634
- return () => clearTimeout(timer);
1635
- }, [
1636
- isAdmin,
1637
- promoCodeInput,
1638
- appliedPromoCode,
1639
- promoCodeError,
1640
- selectedOption,
1641
- selectedStartTime,
1642
- resourceCount,
1643
- ]);
573
+ const handleTermsAcceptedChange = useCallback((checked: boolean) => {
574
+ setTermsAccepted(checked);
575
+ setTermsAcceptedAt(checked ? new Date().toISOString() : null);
576
+ }, []);
1644
577
 
1645
578
  const itineraryDisplayItems = useMemo(() => {
1646
579
  if (!selectedOption || !selectedStartTime) return [];
@@ -1690,706 +623,90 @@ export function PrivateShuttleBookingFlow({
1690
623
  t,
1691
624
  ]);
1692
625
 
1693
- const cancelPendingReservation = useCallback(() => {
1694
- if (paymentSubmitInFlightRef.current) return;
1695
- const pending = pendingReservationRef.current;
1696
- if (pending) {
1697
- pendingReservationRef.current = null;
1698
- cancelReservation(pending.reservationReference).catch(() => {});
1699
- }
1700
- setShowCheckoutModal(false);
1701
- setCheckoutModalData(null);
1702
- setCheckoutClientSecret('');
1703
- }, []);
1704
-
1705
- const cancelPendingReservationBestEffort = useCallback(() => {
1706
- if (paymentSubmitInFlightRef.current) return;
1707
- const pending = pendingReservationRef.current;
1708
- if (pending) {
1709
- pendingReservationRef.current = null;
1710
- cancelReservationBestEffort(pending.reservationReference);
1711
- }
1712
- setShowCheckoutModal(false);
1713
- setCheckoutModalData(null);
1714
- setCheckoutClientSecret('');
1715
- }, []);
1716
-
1717
- // Parent surfaces (dialog close / embedded back) emit this when user abandons the booking flow.
1718
- useEffect(() => {
1719
- const handleAbandon = () => {
1720
- cancelPendingReservation();
1721
- };
1722
- window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
1723
- return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
1724
- }, [cancelPendingReservation]);
1725
-
1726
- useEffect(() => {
1727
- const handlePageHide = () => {
1728
- cancelPendingReservationBestEffort();
1729
- };
1730
- window.addEventListener('pagehide', handlePageHide);
1731
- return () => window.removeEventListener('pagehide', handlePageHide);
1732
- }, [cancelPendingReservationBestEffort]);
1733
-
1734
- const handleCheckout = async () => {
1735
- if (!selectedDate || !selectedStartTime || passengerCount < 1) {
1736
- setError('Please select a date, start time, and passenger count');
1737
- return;
1738
- }
1739
- if (
1740
- !isAdmin &&
1741
- !privateShuttleStartTimeAllowed(
1742
- selectedDate,
1743
- selectedStartTime,
1744
- companyTimezone,
1745
- bookingCutoffNow,
1746
- bookingCutoffMinutes,
1747
- )
1748
- ) {
1749
- setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
1750
- return;
1751
- }
1752
- if (
1753
- product.pickupLocations &&
1754
- product.pickupLocations.length > 0 &&
1755
- !pickupLocationId &&
1756
- !customPickupAddress &&
1757
- !pickupLocationSkipped
1758
- ) {
1759
- setError(t('booking.selectPickupLocation'));
1760
- return;
1761
- }
1762
- if (!isProviderChangeMode) {
1763
- if (!email) {
1764
- setError(t('booking.enterEmail') || 'Please enter your email address');
1765
- return;
1766
- }
1767
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1768
- setError(t('booking.invalidEmail') || 'Please enter a valid email address');
1769
- return;
1770
- }
1771
- if (!firstName?.trim()) {
1772
- setError(t('booking.enterFirstName') || 'Please enter your first name');
1773
- return;
1774
- }
1775
- if (!lastName?.trim()) {
1776
- setError(t('booking.enterLastName') || 'Please enter your last name');
1777
- return;
1778
- }
1779
- }
1780
-
1781
- setLoading(true);
1782
- setError('');
1783
- paymentSubmitInFlightRef.current = false;
1784
-
1785
- try {
1786
- if (!selectedOption) {
1787
- setError('No product option selected');
1788
- setLoading(false);
1789
- return;
1790
- }
1791
- if (isProviderChangeMode && onChangeBooking) {
1792
- const bookingItems = [{ category: 'RESOURCE' as const, count: billableResourceCount }];
1793
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
1794
- const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
1795
- await onChangeBooking({
1796
- productId: selectedOption,
1797
- dateTime: selectedDate,
1798
- bookingItems,
1799
- returnAvailabilityId: null,
1800
- pickupLocationId: pickupLocationId ?? null,
1801
- travelerHotel:
1802
- selectedPickupLocation?.name ?? customPickupAddress ?? initialBooking?.travelerHotel ?? null,
1803
- startTime: startTimeISO,
1804
- passengerCount,
1805
- childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : null,
1806
- foodRestrictions: foodRestrictions.trim() || null,
1807
- addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
1808
- cancellationPolicyId: cancellationPolicyId ?? initialBooking?.cancellationPolicyId ?? null,
1809
- promoCode: activePromoCode ?? null,
1810
- newTotalAmount: totalPrice,
1811
- additionalHoursCount: isAdmin ? additionalHoursCount : null,
1812
- authoritativeReceipt: providerChangeAuthoritativeReceipt,
1813
- });
1814
- setLoading(false);
1815
- return;
1816
- }
1817
- const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
1818
- clientChannelSource: inferClientBookingSourceFromProductIds(product.productId, selectedOption),
1819
- forcePartnerPortalChannel: partnerPortalBooking,
1820
- forceDashboardSource: bookingAppMode === 'provider-dashboard',
1821
- });
1822
- const bookingItems = [{ category: 'RESOURCE' as const, count: billableResourceCount }];
1823
- const [hours, minutes] = selectedStartTime.split(':').map(Number);
1824
- const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
1825
-
1826
- try {
1827
- const refreshed = await refreshSelectedDateDetailsForCheckout();
1828
- const slot = findMergedPrivateShuttleAvailability(
1829
- refreshed,
1830
- selectedAvailability,
1831
- selectedOption || null,
1832
- selectedDate || null,
1833
- companyTimezone
1834
- );
1835
- const passengersToReserve = isSpecialRequest
1836
- ? Math.min(passengerCount, maxVacancies)
1837
- : passengerCount;
1838
- if (!isAdmin && slot?.vacancies != null && (slot.vacancies === 0 || slot.vacancies < passengersToReserve)) {
1839
- setError(
1840
- describePrivateShuttleCapacityConflictMessage({
1841
- passengersRequested: passengerCount,
1842
- vacancies: slot.vacancies,
1843
- })
1844
- );
1845
- setLoading(false);
1846
- return;
1847
- }
1848
- } catch (refreshErr) {
1849
- console.warn('Fresh private shuttle availability check failed before reserve; falling back to reserve validation.', refreshErr);
1850
- }
1851
-
1852
- const specialRequestNote = isSpecialRequest
1853
- ? `Special request: Customer requested ${passengerCount} passengers (${resourceCount} shuttles needed). We reserved ${billableResourceCount} at standard capacity. Team to add ${resourceCount - billableResourceCount} more shuttle(s) and verify fleet availability.`
1854
- : '';
1855
- const userPlanningNotes = draftItineraryPlanningNotes.trim();
1856
- const planningNotes = [specialRequestNote, userPlanningNotes].filter(Boolean).join('\n\n');
1857
-
1858
- let reservation: { reservationReference: string; expiresAt?: string; totalAmount?: number; currency?: string };
1859
- reservation = await createReservation({
1860
- productId: selectedOption,
1861
- dateTime: selectedDate,
1862
- availabilityId: selectedAvailability?.availabilityId || undefined,
1863
- bookingItems,
1864
- currency,
1865
- startTime: startTimeISO,
1866
- passengerCount: isSpecialRequest ? Math.min(passengerCount, maxVacancies) : passengerCount,
1867
- requestedPassengerCount: isSpecialRequest ? passengerCount : undefined,
1868
- pickupLocationId: pickupLocationId || undefined,
1869
- cancellationPolicyId: cancellationPolicyId || undefined,
1870
- promoCode: activePromoCode || undefined,
1871
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1872
- draftItinerary:
1873
- draftItineraryDestinations.length > 0 || planningNotes
1874
- ? {
1875
- destinations: draftItineraryDestinations,
1876
- planningNotes: planningNotes || undefined,
1877
- }
1878
- : undefined,
1879
- childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : undefined,
1880
- foodRestrictions: foodRestrictions.trim() || undefined,
1881
- addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
1882
- additionalHoursCount: isAdmin && additionalHoursCount > 0 ? additionalHoursCount : undefined,
1883
- ...(isAdmin ? { allowOverbook: true } : {}),
1884
- ...(bookingSourceContext.sourceMetadata
1885
- ? {
1886
- source: bookingSourceContext.source,
1887
- sourceMetadata: bookingSourceContext.sourceMetadata,
1888
- source_metadata: bookingSourceContext.source_metadata,
1889
- }
1890
- : {}),
1891
- });
1892
- pendingReservationRef.current = { reservationReference: reservation.reservationReference };
1893
-
1894
- if (!reservation?.reservationReference) {
1895
- throw new Error('Invalid reservation response: missing reservationReference');
1896
- }
1897
-
1898
- const itineraryDisplayForStorage: ItineraryDisplayStep[] = itineraryDisplayItems.map(
1899
- (item, i) => {
1900
- if (item.isProposedStops) {
1901
- return {
1902
- stepType: ItineraryStepType.draft,
1903
- time: 'Proposed stops',
1904
- place: item.label,
1905
- };
1906
- }
1907
- const isPickup = i === 0;
1908
- return {
1909
- stepType: isPickup ? ItineraryStepType.pickup : ItineraryStepType.drop_off,
1910
- time: item.time ?? 'TBD',
1911
- place: item.clickableLabel ?? item.label,
1912
- };
1913
- }
1914
- );
1915
-
1916
- const lines = [
1917
- {
1918
- label: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
1919
- amount: basePrice,
1920
- type: 'TICKET' as const,
1921
- quantity: resourceCount,
1922
- },
1923
- ...addOnSelections
1924
- .map((sel) => {
1925
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1926
- if (!addOn) return null;
1927
- const base = addOn.price ?? 0;
1928
- const hasVariant =
1929
- (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
1930
- sel.variantId;
1931
- const adj = hasVariant
1932
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1933
- : 0;
1934
- const amt = (base + adj) * (sel.quantity ?? 1);
1935
- const variantLabel = hasVariant
1936
- ? addOn.variants?.find((v) => v.id === sel.variantId)?.label
1937
- : null;
1938
- const qty = sel.quantity ?? 1;
1939
- return {
1940
- label: variantLabel
1941
- ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}`
1942
- : addOn.name,
1943
- amount: amt,
1944
- type: 'FEE' as const,
1945
- };
1946
- })
1947
- .filter(Boolean) as Array<{ label: string; amount: number; type: 'FEE' }>,
1948
- ...(additionalHoursAmount > 0
1949
- ? [
1950
- {
1951
- label:
1952
- additionalHoursCount === 1
1953
- ? 'Additional hour'
1954
- : `Additional hours (${additionalHoursCount})`,
1955
- amount: additionalHoursAmount,
1956
- type: 'ADDITIONAL_HOURS' as const,
1957
- },
1958
- ]
1959
- : []),
1960
- ...perBookingFeeLineItems.map((fee) => ({
1961
- label: fee.name,
1962
- amount: fee.totalAmount,
1963
- type: 'FEE' as const,
1964
- })),
1965
- ...(cancellationPolicyFee > 0 && selectedCancellationPolicy
1966
- ? [
1967
- {
1968
- label: selectedCancellationPolicy.label,
1969
- amount: cancellationPolicyFee,
1970
- type: 'CANCELLATION_UPGRADE' as const,
1971
- },
1972
- ]
1973
- : []),
1974
- ...(effectiveTaxAmount > 0
1975
- ? [
1976
- {
1977
- label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
1978
- amount: effectiveTaxAmount,
1979
- type: 'TAX' as const,
1980
- },
1981
- ]
1982
- : []),
1983
- ];
1984
- const receiptTotal = depositInfo ? depositInfo.totalPrice : totalPrice;
1985
- const linesWithPromo = [
1986
- ...lines,
1987
- ...(effectivePromoDiscountAmount > 0
1988
- ? [
1989
- {
1990
- label:
1991
- activePromoCode
1992
- ? `Promo: ${activePromoCode}`
1993
- : (t('booking.discount') || 'Discount'),
1994
- amount: -effectivePromoDiscountAmount,
1995
- type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
1996
- },
1997
- ]
1998
- : []),
1999
- ];
2000
- const checkoutBreakdown = buildCheckoutBreakdown({
2001
- lines: linesWithPromo,
2002
- totalAmount: receiptTotal,
2003
- currency,
2004
- roundingLabel: t('booking.rounding') || 'Rounding',
2005
- });
2006
-
2007
- if (flowUi?.partnerDeferredInvoice) {
2008
- const depositAmount = depositInfo ? depositInfo.depositAmount : 0;
2009
- const balanceAmount = depositInfo ? depositInfo.balanceAmount : receiptTotal;
2010
- const confirmedBooking = await confirmPartnerBookingWithoutPayment({
2011
- reservationReference: reservation.reservationReference,
2012
- productId: product.productId,
2013
- optionId: selectedOption,
2014
- date: selectedDate,
2015
- time: selectedStartTime,
2016
- customerEmail: email || undefined,
2017
- customerFirstName: firstName.trim() || undefined,
2018
- customerLastName: lastName.trim() || undefined,
2019
- ...optionalCheckoutContactFields(phoneNumber),
2020
- ...optionalCheckoutContactFields(phoneNumber),
2021
- currency,
2022
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
2023
- pickupLocationId: pickupLocationId || undefined,
2024
- itineraryDisplay: itineraryDisplayForStorage,
2025
- termsAcceptedAt: termsAcceptedAt ?? undefined,
2026
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
2027
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
2028
- checkoutBreakdown,
2029
- depositAmount,
2030
- balanceAmount,
2031
- totalAmount: receiptTotal,
2032
- balanceChargeDaysBefore: depositInfo ? (privateShuttleConfig?.balanceChargeDaysBefore ?? 7) : undefined,
2033
- ...bookingSourceContext,
2034
- });
2035
- pendingReservationRef.current = null;
2036
- const ref = formatBookingRefForDisplay(confirmedBooking.bookingReference);
2037
- const ln = lastName.trim();
2038
- onSuccess?.({
2039
- reservationReference: reservation.reservationReference,
2040
- bookingReference: confirmedBooking.bookingReference,
2041
- });
2042
- if (onShowManage) {
2043
- onShowManage({ ref, lastName: ln });
2044
- } else {
2045
- window.location.href = `/manage-booking?ref=${ref}&lastName=${encodeURIComponent(ln)}&booking_complete=1`;
2046
- }
2047
- setLoading(false);
2048
- return;
2049
- }
2050
-
2051
- if (isAdmin && depositInfo) {
2052
- const paymentIntent = await createPaymentIntent({
2053
- productId: product.productId,
2054
- optionId: selectedOption,
2055
- date: selectedDate,
2056
- time: selectedStartTime,
2057
- quantity: resourceCount,
2058
- customerEmail: email,
2059
- customerFirstName: firstName.trim() || undefined,
2060
- customerLastName: lastName.trim() || undefined,
2061
- ...optionalCheckoutContactFields(phoneNumber),
2062
- ...optionalCheckoutContactFields(phoneNumber),
2063
- currency,
2064
- reservationReference: reservation.reservationReference,
2065
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
2066
- pickupLocationId: pickupLocationId || undefined,
2067
- itineraryDisplay: itineraryDisplayForStorage,
2068
- termsAcceptedAt: termsAcceptedAt ?? undefined,
2069
- cancellationPolicyId: cancellationPolicyId || undefined,
2070
- promoCode: activePromoCode || undefined,
2071
- checkoutBreakdown,
2072
- paymentPlanType: 'DEPOSIT',
2073
- depositAmount: depositInfo.depositAmount,
2074
- balanceAmount: depositInfo.balanceAmount,
2075
- totalAmount: depositInfo.totalPrice,
2076
- balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
2077
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
2078
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
2079
- ...bookingSourceContext,
2080
- });
2081
- setAdminChoiceData({
2082
- reservationReference: reservation.reservationReference,
2083
- reservationExpiration: reservation.expiresAt,
2084
- checkoutBreakdown,
2085
- totalAmount: depositInfo.totalPrice,
2086
- datePart: selectedDate,
2087
- timePart: selectedStartTime,
2088
- availabilityProductOptionId: selectedOption,
2089
- itineraryDisplay: itineraryDisplayForStorage,
2090
- clientSecret: paymentIntent.clientSecret ?? '',
2091
- ticketLinesForModal: [
2092
- {
2093
- line: {
2094
- category: 'Shuttle',
2095
- qty: resourceCount,
2096
- pricePerUnit: resourcePrice,
2097
- itemTotal: basePrice,
2098
- },
2099
- breakdown: null,
2100
- },
2101
- ],
2102
- feeLineItems: [
2103
- ...addOnSelections
2104
- .map((sel) => {
2105
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
2106
- if (!addOn) return null;
2107
- const base = addOn.price ?? 0;
2108
- const hasVariant =
2109
- (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
2110
- sel.variantId;
2111
- const adj = hasVariant
2112
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
2113
- : 0;
2114
- return {
2115
- name: addOn.name,
2116
- totalAmount: (base + adj) * (sel.quantity ?? 1),
2117
- };
2118
- })
2119
- .filter(Boolean) as { name: string; totalAmount: number }[],
2120
- ...perBookingFeeLineItems,
2121
- ],
2122
- cancellationPolicyFee,
2123
- cancellationPolicyLabel: selectedCancellationPolicy?.label,
2124
- subtotal,
2125
- tax: effectiveTaxAmount,
2126
- totalQuantity: resourceCount,
2127
- isTaxIncludedInPrice,
2128
- taxRate,
2129
- promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
2130
- discountLabel: activePromoCode ? `Promo: ${activePromoCode}` : undefined,
2131
- });
2132
- setShowAdminPaymentChoice(true);
2133
- setLoading(false);
2134
- return;
2135
- }
2136
-
2137
- const paymentIntent = await createPaymentIntent({
2138
- productId: product.productId,
2139
- optionId: selectedOption,
2140
- date: selectedDate,
2141
- time: selectedStartTime,
2142
- quantity: resourceCount,
2143
- customerEmail: email,
2144
- customerFirstName: firstName.trim() || undefined,
2145
- customerLastName: lastName.trim() || undefined,
2146
- ...optionalCheckoutContactFields(phoneNumber),
2147
- currency,
2148
- reservationReference: reservation.reservationReference,
2149
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
2150
- pickupLocationId: pickupLocationId || undefined,
2151
- itineraryDisplay: itineraryDisplayForStorage,
2152
- termsAcceptedAt: termsAcceptedAt ?? undefined,
2153
- cancellationPolicyId: cancellationPolicyId || undefined,
2154
- promoCode: activePromoCode || undefined,
2155
- checkoutBreakdown,
2156
- ...(depositInfo && {
2157
- paymentPlanType: 'DEPOSIT' as const,
2158
- depositAmount: depositInfo.depositAmount,
2159
- balanceAmount: depositInfo.balanceAmount,
2160
- totalAmount: depositInfo.totalPrice,
2161
- balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
2162
- }),
2163
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
2164
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
2165
- ...bookingSourceContext,
2166
- });
2167
-
2168
- if (paymentIntent.freeBooking) {
2169
- const freeResult = await confirmFreeBooking({
2170
- reservationReference: reservation.reservationReference,
2171
- productId: product.productId,
2172
- optionId: selectedOption,
2173
- date: selectedDate,
2174
- time: selectedStartTime,
2175
- customerEmail: email || undefined,
2176
- customerFirstName: firstName.trim() || undefined,
2177
- customerLastName: lastName.trim() || undefined,
2178
- ...optionalCheckoutContactFields(phoneNumber),
2179
- ...optionalCheckoutContactFields(phoneNumber),
2180
- currency,
2181
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
2182
- pickupLocationId: pickupLocationId || undefined,
2183
- itineraryDisplay: itineraryDisplayForStorage,
2184
- termsAcceptedAt: termsAcceptedAt ?? undefined,
2185
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
2186
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
2187
- ...bookingSourceContext,
2188
- });
2189
- pendingReservationRef.current = null;
2190
- const ref = formatBookingRefForDisplay(freeResult.bookingReference);
2191
- onSuccess?.({ reservationReference: reservation.reservationReference });
2192
- if (onShowManage) {
2193
- onShowManage({ ref, lastName: lastName.trim() });
2194
- } else {
2195
- window.location.href = `/manage-booking?ref=${ref}&lastName=${encodeURIComponent(lastName.trim())}&booking_complete=1`;
2196
- }
2197
- setLoading(false);
2198
- return;
2199
- }
2200
-
2201
- const ticketLinesForModal: CheckoutModalLineItem[] = [
2202
- {
2203
- line: {
2204
- category: 'Shuttle',
2205
- qty: resourceCount,
2206
- pricePerUnit: resourcePrice,
2207
- itemTotal: basePrice,
2208
- },
2209
- breakdown: null,
2210
- },
2211
- ];
2212
- const addOnFeeLineItemsForModal = addOnSelections
2213
- .map((sel) => {
2214
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
2215
- if (!addOn) return null;
2216
- const base = addOn.price ?? 0;
2217
- const hasVariant =
2218
- (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
2219
- sel.variantId;
2220
- const adj = hasVariant
2221
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
2222
- : 0;
2223
- return {
2224
- name: addOn.name,
2225
- totalAmount: (base + adj) * (sel.quantity ?? 1),
2226
- };
2227
- })
2228
- .filter(Boolean) as { name: string; totalAmount: number }[];
2229
- const feeLineItemsForModal = [...addOnFeeLineItemsForModal, ...perBookingFeeLineItems];
2230
-
2231
- setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
2232
- setCheckoutModalData({
2233
- reservationReference: reservation.reservationReference,
2234
- reservationExpiration: reservation.expiresAt,
2235
- customerLastName: lastName.trim(),
2236
- bookingDate: selectedDate,
2237
- ticketLines: ticketLinesForModal,
2238
- feeLineItems: feeLineItemsForModal,
2239
- returnPriceAdjustment: 0,
2240
- subtotal: depositInfo ? depositInfo.totalPrice : totalPrice,
2241
- tax: effectiveTaxAmount,
2242
- total: depositInfo ? depositInfo.depositAmount : totalPrice,
2243
- totalQuantity: resourceCount,
2244
- isTaxIncludedInPrice,
2245
- taxRate,
2246
- cancellationPolicyFee,
2247
- cancellationPolicyLabel: selectedCancellationPolicy?.label,
2248
- promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
2249
- discountLabel: activePromoCode ? `Promo: ${activePromoCode}` : undefined,
2250
- isDepositPayment: !!depositInfo,
2251
- balanceChargeDaysBefore: depositInfo ? (privateShuttleConfig?.balanceChargeDaysBefore ?? 7) : undefined,
2252
- });
2253
- setShowCheckoutModal(true);
2254
- setLoading(false);
2255
- } catch (err) {
2256
- if (isInsufficientCapacityReserveError(err)) {
2257
- try {
2258
- const merged = await reloadAvailabilitiesAfterReserveConflict();
2259
- const slot = findMergedPrivateShuttleAvailability(
2260
- merged,
2261
- selectedAvailability,
2262
- selectedOption || null,
2263
- selectedDate || null,
2264
- companyTimezone
2265
- );
2266
- setError(
2267
- describePrivateShuttleCapacityConflictMessage({
2268
- passengersRequested: passengerCount,
2269
- vacancies: slot?.vacancies ?? null,
2270
- })
2271
- );
2272
- reportReserveCapacityConflictClientContext({
2273
- flow: 'private_shuttle',
2274
- productId: product.productId,
2275
- selectedDate: selectedDate || null,
2276
- outboundDateTime: selectedAvailability?.dateTime ?? null,
2277
- outboundVacanciesAfterRefresh: slot?.vacancies ?? null,
2278
- returnVacanciesAfterRefresh: null,
2279
- partySizeOrPassengers: passengerCount,
2280
- hasReturnSelection: false,
2281
- returnAvailabilityId: null,
2282
- reloadAvailabilitiesSucceeded: true,
2283
- });
2284
- } catch (reloadErr) {
2285
- setError(
2286
- describePrivateShuttleCapacityConflictMessage({
2287
- passengersRequested: passengerCount,
2288
- vacancies: null,
2289
- })
2290
- );
2291
- reportReserveCapacityConflictClientContext({
2292
- flow: 'private_shuttle',
2293
- productId: product.productId,
2294
- selectedDate: selectedDate || null,
2295
- outboundDateTime: selectedAvailability?.dateTime ?? null,
2296
- outboundVacanciesAfterRefresh: null,
2297
- returnVacanciesAfterRefresh: null,
2298
- partySizeOrPassengers: passengerCount,
2299
- hasReturnSelection: false,
2300
- returnAvailabilityId: null,
2301
- reloadAvailabilitiesSucceeded: false,
2302
- reloadErrorMessage:
2303
- reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
2304
- });
2305
- }
2306
- setLoading(false);
2307
- return;
2308
- }
2309
- setError(err instanceof Error ? err.message : 'Something went wrong');
2310
- setLoading(false);
2311
- }
2312
- };
2313
-
2314
- const handleConfirmWithoutPayment = async () => {
2315
- if (!adminChoiceData) return;
2316
- setLoading(true);
2317
- setError('');
2318
- try {
2319
- const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
2320
- clientChannelSource: inferClientBookingSourceFromProductIds(
2321
- product.productId,
2322
- adminChoiceData.availabilityProductOptionId,
2323
- ),
2324
- forcePartnerPortalChannel: partnerPortalBooking,
2325
- forceDashboardSource: bookingAppMode === 'provider-dashboard',
2326
- });
2327
- await confirmBookingWithoutPayment({
2328
- reservationReference: adminChoiceData.reservationReference,
2329
- productId: product.productId,
2330
- optionId: adminChoiceData.availabilityProductOptionId,
2331
- date: adminChoiceData.datePart,
2332
- time: adminChoiceData.timePart,
2333
- customerEmail: email || undefined,
2334
- customerFirstName: firstName.trim() || undefined,
2335
- customerLastName: lastName.trim() || undefined,
2336
- ...optionalCheckoutContactFields(phoneNumber),
2337
- currency,
2338
- travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
2339
- pickupLocationId: pickupLocationId || undefined,
2340
- itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
2341
- termsAcceptedAt: termsAcceptedAt ?? undefined,
2342
- skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
2343
- disableAutoCommunications: disableAutoCommunications ? true : undefined,
2344
- checkoutBreakdown: adminChoiceData.checkoutBreakdown,
2345
- depositAmount: 0,
2346
- balanceAmount: adminChoiceData.totalAmount,
2347
- totalAmount: adminChoiceData.totalAmount,
2348
- ...bookingSourceContext,
2349
- });
2350
- pendingReservationRef.current = null;
2351
- const ref = formatBookingRefForDisplay(adminChoiceData.reservationReference);
2352
- setShowAdminPaymentChoice(false);
2353
- setAdminChoiceData(null);
2354
- onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
2355
- if (onShowManage) {
2356
- onShowManage({ ref, lastName: lastName.trim() });
2357
- } else {
2358
- window.location.href = `/manage-booking?ref=${ref}&lastName=${encodeURIComponent(lastName.trim())}&booking_complete=1`;
2359
- }
2360
- } catch (err) {
2361
- setError(err instanceof Error ? err.message : 'Failed to confirm booking');
2362
- } finally {
2363
- setLoading(false);
2364
- }
2365
- };
2366
-
2367
- const handlePayNow = () => {
2368
- if (!adminChoiceData) return;
2369
- setShowAdminPaymentChoice(false);
2370
- setCheckoutClientSecret(adminChoiceData.clientSecret);
2371
- setCheckoutModalData({
2372
- reservationReference: adminChoiceData.reservationReference,
2373
- reservationExpiration: adminChoiceData.reservationExpiration,
2374
- customerLastName: lastName.trim(),
2375
- bookingDate: adminChoiceData.datePart,
2376
- ticketLines: adminChoiceData.ticketLinesForModal,
2377
- feeLineItems: adminChoiceData.feeLineItems,
2378
- returnPriceAdjustment: 0,
2379
- subtotal: adminChoiceData.subtotal,
2380
- tax: adminChoiceData.tax,
2381
- total: adminChoiceData.totalAmount,
2382
- totalQuantity: adminChoiceData.totalQuantity,
2383
- isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
2384
- taxRate: adminChoiceData.taxRate,
2385
- cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
2386
- cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
2387
- promoDiscountAmount: adminChoiceData.promoDiscountAmount,
2388
- discountLabel: adminChoiceData.discountLabel,
2389
- });
2390
- setShowCheckoutModal(true);
2391
- setAdminChoiceData(null);
2392
- };
626
+ const {
627
+ showCheckoutModal,
628
+ checkoutClientSecret,
629
+ checkoutModalData,
630
+ showAdminPaymentChoice,
631
+ adminChoiceData,
632
+ cancelPendingReservation,
633
+ handleCheckout,
634
+ handleConfirmWithoutPayment,
635
+ handlePayNow,
636
+ handlePaymentSubmitStart,
637
+ handlePaymentSubmitError,
638
+ handleAdminPaymentChoiceCancel,
639
+ } = usePrivateShuttleCheckoutController({
640
+ selectedOption,
641
+ selectedDate,
642
+ selectedStartTime,
643
+ selectedAvailability,
644
+ companyTimezone,
645
+ bookingCutoffNow,
646
+ bookingCutoffMinutes,
647
+ productId: product.productId,
648
+ currency,
649
+ bookingSourceAttribution,
650
+ partnerPortalBooking,
651
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
652
+ isAdmin,
653
+ isProviderChangeMode,
654
+ onChangeBooking,
655
+ initialBooking,
656
+ email,
657
+ firstName,
658
+ lastName,
659
+ phoneNumber,
660
+ passengerCount,
661
+ maxVacancies,
662
+ isSpecialRequest,
663
+ billableResourceCount,
664
+ resourceCount,
665
+ resourcePrice,
666
+ basePrice,
667
+ pricingConfig,
668
+ refreshSelectedDateDetailsForCheckout,
669
+ reloadAvailabilitiesAfterReserveConflict,
670
+ totalPrice,
671
+ subtotal,
672
+ activePromoCode,
673
+ cancellationPolicyId,
674
+ selectedCancellationPolicyLabel: selectedCancellationPolicy?.label,
675
+ cancellationPolicyFee,
676
+ addOnSelections,
677
+ addOns,
678
+ additionalHoursCount,
679
+ additionalHoursAmount,
680
+ perBookingFeeLineItems,
681
+ effectiveTaxAmount,
682
+ taxRate,
683
+ effectivePromoDiscountAmount,
684
+ isGiftCard,
685
+ isTaxIncludedInPrice,
686
+ depositInfo,
687
+ depositConfig: privateShuttleConfig?.depositConfig,
688
+ balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore,
689
+ selectedPickupLocationName: selectedPickupLocation?.name ?? null,
690
+ customPickupAddress,
691
+ pickupLocationId,
692
+ pickupLocationSkipped,
693
+ hasPickupLocations: Boolean(product.pickupLocations?.length),
694
+ draftItineraryDestinations,
695
+ draftItineraryPlanningNotes,
696
+ childSafetySeatsCount,
697
+ foodRestrictions,
698
+ itineraryDisplayItems,
699
+ providerChangeAuthoritativeReceipt,
700
+ partnerDeferredInvoice: Boolean(flowUi?.partnerDeferredInvoice),
701
+ termsAcceptedAt,
702
+ skipConfirmationCommunications,
703
+ disableAutoCommunications,
704
+ setLoading,
705
+ setError,
706
+ onSuccess,
707
+ onShowManage,
708
+ t,
709
+ });
2393
710
 
2394
711
  if (activeOptions.length === 0) {
2395
712
  return (
@@ -2401,101 +718,35 @@ export function PrivateShuttleBookingFlow({
2401
718
  );
2402
719
  }
2403
720
 
2404
- const config = productId
2405
- ? (catalog.getProductByIdOrSlug(productId) as {
2406
- display?: {
2407
- collageImageIds?: string[];
2408
- imageIds?: string[];
2409
- };
2410
- } | null)
2411
- : null;
2412
- const displayProducts = catalog.getProducts(defaultStrings) as Record<
2413
- string,
2414
- { id: string; videoUrl?: VideoSources }
2415
- >;
2416
- const displayProduct = Object.values(displayProducts).find((p) => p.id === productId);
2417
- const collageImageIds = config?.display?.collageImageIds ?? config?.display?.imageIds ?? [];
2418
- const hasVideo = !!displayProduct?.videoUrl;
2419
- const hasImages = collageImageIds.length > 0;
2420
-
2421
721
  return (
2422
722
  <div className={`booking-flow-root private-shuttle-booking-flow ${styles.root} space-y-8`}>
2423
- <AdminPaymentChoiceModal
2424
- open={!!(showAdminPaymentChoice && adminChoiceData)}
2425
- totalAmount={adminChoiceData?.totalAmount ?? 0}
723
+ <PrivateShuttleCheckoutDialogs
724
+ showAdminPaymentChoice={showAdminPaymentChoice}
725
+ adminChoiceData={adminChoiceData}
726
+ checkoutModalData={checkoutModalData}
727
+ showCheckoutModal={showCheckoutModal}
728
+ checkoutClientSecret={checkoutClientSecret}
2426
729
  currency={currency}
730
+ locale={locale}
2427
731
  loading={loading}
2428
732
  error={error}
2429
733
  onPayNow={handlePayNow}
2430
734
  onConfirmWithoutPayment={handleConfirmWithoutPayment}
2431
- onCancel={() => {
2432
- setShowAdminPaymentChoice(false);
2433
- setAdminChoiceData(null);
2434
- setError('');
2435
- }}
735
+ onAdminPaymentChoiceCancel={handleAdminPaymentChoiceCancel}
736
+ onCheckoutClose={cancelPendingReservation}
737
+ onPaymentSubmitStart={handlePaymentSubmitStart}
738
+ onPaymentSubmitError={handlePaymentSubmitError}
739
+ getSuccessUrl={getSuccessUrl}
740
+ t={t}
2436
741
  />
2437
- {checkoutModalData && (
2438
- <CheckoutModal
2439
- open={showCheckoutModal}
2440
- onClose={cancelPendingReservation}
2441
- onPaymentSubmitStart={() => {
2442
- paymentSubmitInFlightRef.current = true;
2443
- }}
2444
- onPaymentSubmitError={() => {
2445
- paymentSubmitInFlightRef.current = false;
2446
- }}
2447
- clientSecret={checkoutClientSecret}
2448
- reservationReference={checkoutModalData.reservationReference}
2449
- reservationExpiration={checkoutModalData.reservationExpiration}
2450
- customerLastName={checkoutModalData.customerLastName}
2451
- successUrlOverride={
2452
- getSuccessUrl
2453
- ? getSuccessUrl({
2454
- reservationRef: checkoutModalData.reservationReference,
2455
- lastName: checkoutModalData.customerLastName ?? '',
2456
- focusDate: checkoutModalData.bookingDate,
2457
- })
2458
- : undefined
2459
- }
2460
- ticketLines={checkoutModalData.ticketLines}
2461
- feeLineItems={checkoutModalData.feeLineItems}
2462
- returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
2463
- cancellationPolicyFee={checkoutModalData.cancellationPolicyFee ?? 0}
2464
- cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
2465
- subtotal={checkoutModalData.subtotal}
2466
- tax={checkoutModalData.tax}
2467
- total={checkoutModalData.total}
2468
- promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
2469
- discountLabel={checkoutModalData.discountLabel}
2470
- totalQuantity={checkoutModalData.totalQuantity}
2471
- isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
2472
- taxRate={checkoutModalData.taxRate}
2473
- currency={currency}
2474
- locale={locale}
2475
- t={t}
2476
- isDepositPayment={checkoutModalData.isDepositPayment}
2477
- balanceChargeDaysBefore={checkoutModalData.balanceChargeDaysBefore}
2478
- />
2479
- )}
2480
-
2481
- {productId && flowUi?.showCollage !== false && (hasVideo || hasImages) && (
2482
- <div className="booking-collage-wrapper">
2483
- <BookingFlowCollage
2484
- video={displayProduct?.videoUrl}
2485
- videoPosterImageId={config?.display?.imageIds?.[0]}
2486
- imageIds={
2487
- hasImages
2488
- ? collageImageIds
2489
- : (config?.display?.imageIds?.[0] ? [config.display.imageIds[0]] : [])
2490
- }
2491
- altPrefix={product.name}
2492
- />
2493
- </div>
2494
- )}
2495
742
 
2496
- {flowUi?.showTourDescription !== false && (
2497
- <TourDescription productSlug={productId} locale={locale} />
2498
- )}
743
+ <PrivateShuttleMediaIntro
744
+ productId={productId}
745
+ productName={product.name}
746
+ locale={locale}
747
+ showCollage={flowUi?.showCollage !== false}
748
+ showTourDescription={flowUi?.showTourDescription !== false}
749
+ />
2499
750
 
2500
751
  {loadingAvailabilities && availabilities.length === 0 ? (
2501
752
  <div className="flex flex-col items-center justify-center py-12 gap-4">
@@ -2528,820 +779,188 @@ export function PrivateShuttleBookingFlow({
2528
779
  />
2529
780
  </div>
2530
781
 
2531
- {selectedDate && optionsAvailableForSelectedDate.length > 0 && (
2532
- <div className={styles.section}>
2533
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2534
- {t('booking.selectTourOption')}
2535
- </label>
2536
- <div className={styles.optionGrid}>
2537
- {[...optionsAvailableForSelectedDate]
2538
- .sort((a, b) => (b.mostPopular ? 1 : 0) - (a.mostPopular ? 1 : 0))
2539
- .map((option) => {
2540
- const isSelected = selectedOption === option.optionId;
2541
- const baseDurationMinutes =
2542
- option.privateShuttleConfig?.baseDurationMinutes ?? 0;
2543
- const hours = baseDurationMinutes / 60;
2544
- const price = getOptionPrice(option.optionId);
2545
- const formattedPrice = formatCurrencyAmount(
2546
- price,
2547
- currency,
2548
- locale as 'en' | 'fr'
2549
- );
2550
- const hoursRounded = Math.round(hours);
2551
- const isMostPopular =
2552
- optionsAvailableForSelectedDate.length > 1 && option.mostPopular;
2553
- return (
2554
- <button
2555
- key={option.optionId}
2556
- type="button"
2557
- onClick={() => handleOptionSelect(option.optionId)}
2558
- className={`${styles.btnOption} private-shuttle-btn-option ${
2559
- isMostPopular ? styles.btnOptionWithBadge : ''
2560
- } ${
2561
- isSelected ? styles.btnOptionSelected : styles.btnOptionDefault
2562
- }`}
2563
- >
2564
- <div className="font-semibold">{option.name}</div>
2565
- {price > 0 && hours >= 1 && (
2566
- <div
2567
- className={`mt-1 text-xs ${
2568
- isSelected ? 'text-emerald-100' : 'text-stone-500'
2569
- }`}
2570
- >
2571
- {(t('booking.startingAtForHours') ?? 'Starting at {price} per shuttle for {hours}.')
2572
- .replace('{price}', formattedPrice)
2573
- .replace('{hours}', `${hoursRounded} ${t('booking.hoursUnit') || 'hours'}`)}
2574
- </div>
2575
- )}
2576
- {isMostPopular && (
2577
- <span className={styles.badge}>
2578
- {t('booking.mostPopular')}
2579
- </span>
2580
- )}
2581
- </button>
2582
- );
2583
- })}
2584
- </div>
2585
- </div>
2586
- )}
782
+ <PrivateShuttleOptionSection
783
+ selectedDate={selectedDate}
784
+ options={optionsAvailableForSelectedDate}
785
+ selectedOption={selectedOption}
786
+ currency={currency}
787
+ locale={locale}
788
+ t={t}
789
+ getOptionPrice={getOptionPrice}
790
+ onOptionSelect={handleOptionSelect}
791
+ />
2587
792
 
2588
793
  {selectedOption && selectedAvailability && (
2589
- <div className={styles.section}>
2590
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2591
- Number of Passengers
2592
- </label>
2593
- <div className={styles.passengerBox}>
2594
- <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
2595
- <div>
2596
- <div className="font-medium text-stone-900">Passengers</div>
2597
- <div className="text-sm text-stone-600">
2598
- {resourceCount} {resourceCount === 1 ? 'shuttle' : 'shuttles'} needed
2599
- </div>
2600
- </div>
2601
- {(() => {
2602
- const maxVacancies = selectedAvailability?.vacancies || 0;
2603
- const passengerSelectMin = 1;
2604
- const specialRequestMin = maxVacancies + 1;
2605
- const showSpecialRequestInput = isSpecialRequestMode;
2606
- const canLeaveSpecialRequest = true;
2607
- return (
2608
- <>
2609
- {showSpecialRequestInput ? (
2610
- <div className="flex flex-col sm:flex-row sm:items-center gap-2">
2611
- <input
2612
- type="number"
2613
- min={specialRequestMin}
2614
- max={100}
2615
- value={specialRequestInputValue || String(passengerCount)}
2616
- onChange={(e) => {
2617
- const raw = e.target.value;
2618
- setSpecialRequestInputValue(raw);
2619
- const val = parseInt(raw, 10);
2620
- if (!Number.isNaN(val) && val >= specialRequestMin && val <= 100) {
2621
- setPassengerCount(val);
2622
- }
2623
- }}
2624
- onBlur={() => {
2625
- const val = parseInt(specialRequestInputValue, 10);
2626
- const clamped = Math.max(
2627
- specialRequestMin,
2628
- Math.min(100, Number.isNaN(val) ? specialRequestMin : val)
2629
- );
2630
- setPassengerCount(clamped);
2631
- setSpecialRequestInputValue(String(clamped));
2632
- }}
2633
- className={`${styles.input} ${styles.passengerInput}`}
2634
- style={{ minWidth: '4.5rem', textAlign: 'center' }}
2635
- />
2636
- {canLeaveSpecialRequest && (
2637
- <button
2638
- type="button"
2639
- onClick={() => {
2640
- setIsSpecialRequestMode(false);
2641
- setSpecialRequestInputValue('');
2642
- setPassengerCount(Math.max(1, maxVacancies));
2643
- }}
2644
- className="text-sm text-stone-600 underline hover:text-stone-800"
2645
- >
2646
- Back to standard ({maxVacancies} max)
2647
- </button>
2648
- )}
2649
- </div>
2650
- ) : passengerSelectMin <= maxVacancies ? (
2651
- <select
2652
- value={passengerCount}
2653
- onChange={(e) =>
2654
- handlePassengerCountChange(Number(e.target.value))
2655
- }
2656
- className={`${styles.input} ${styles.passengerSelect}`}
2657
- style={{ minWidth: '4.5rem', textAlign: 'center' }}
2658
- >
2659
- {Array.from(
2660
- {
2661
- length: Math.max(1, maxVacancies - passengerSelectMin + 1),
2662
- },
2663
- (_, i) => i + passengerSelectMin
2664
- ).map((n) => (
2665
- <option key={n} value={n}>
2666
- {n}
2667
- </option>
2668
- ))}
2669
- </select>
2670
- ) : null}
2671
- </>
2672
- );
2673
- })()}
2674
- </div>
2675
- <div className="text-xs text-stone-500 mt-2">
2676
- Each shuttle can accommodate up to {RESOURCE_CAPACITY} passengers.
2677
- {resourceCount > 1 && (
2678
- <span>
2679
- {' '}
2680
- You&apos;ll need {resourceCount} shuttles for {passengerCount}{' '}
2681
- passengers.
2682
- </span>
2683
- )}
2684
- </div>
2685
- {(() => {
2686
- const maxVacancies = selectedAvailability?.vacancies || 0;
2687
- if (maxVacancies > 0 && !isSpecialRequestMode) {
2688
- return (
2689
- <div className="mt-3 pt-3 border-t border-stone-200 text-right">
2690
- <button
2691
- type="button"
2692
- onClick={() => {
2693
- const initial = maxVacancies + 1;
2694
- setIsSpecialRequestMode(true);
2695
- setPassengerCount(initial);
2696
- setSpecialRequestInputValue(String(initial));
2697
- }}
2698
- className={`${styles.specialRequestLink} text-emerald-600 hover:text-emerald-700 underline`}
2699
- >
2700
- Special request for more than {maxVacancies} passengers
2701
- </button>
2702
- </div>
2703
- );
2704
- }
2705
- if (isSpecialRequestMode && passengerCount > maxVacancies) {
2706
- return (
2707
- <div className="mt-3 pt-3 border-t border-stone-200">
2708
- <p className="text-sm text-amber-700 bg-amber-50 p-3 rounded-lg">
2709
- The team will review your request to ensure we can pull{' '}
2710
- {resourceCount} {resourceCount === 1 ? 'shuttle' : 'shuttles'}{' '}
2711
- from our fleet. You&apos;ll pay the full amount for {resourceCount}{' '}
2712
- {resourceCount === 1 ? 'shuttle' : 'shuttles'}; we&apos;ve reserved{' '}
2713
- {billableResourceCount} at standard capacity and the team will add the rest.
2714
- </p>
2715
- </div>
2716
- );
2717
- }
2718
- return null;
2719
- })()}
2720
- </div>
2721
- </div>
794
+ <PrivateShuttlePassengerSection
795
+ passengerCount={passengerCount}
796
+ resourceCount={resourceCount}
797
+ billableResourceCount={billableResourceCount}
798
+ maxVacancies={selectedAvailability.vacancies || 0}
799
+ isSpecialRequestMode={isSpecialRequestMode}
800
+ specialRequestInputValue={specialRequestInputValue}
801
+ onPassengerCountChange={handlePassengerCountChange}
802
+ onPassengerCountOverride={setPassengerCount}
803
+ onSpecialRequestModeChange={setIsSpecialRequestMode}
804
+ onSpecialRequestInputValueChange={setSpecialRequestInputValue}
805
+ />
2722
806
  )}
2723
807
 
2724
808
  {selectedOption && selectedAvailability && passengerCount > 0 && (
2725
- <div className={styles.section}>
2726
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2727
- {t('booking.selectPickupTime')}
2728
- </label>
2729
- {(suggestedStartTimes.length > 0 || isCustomTimeMode) && (
2730
- <div className="flex flex-wrap gap-2">
2731
- {suggestedStartTimes.map((time) => {
2732
- const isSelected = !isCustomTimeMode && selectedStartTime === time;
2733
- const [hours, minutes] = time.split(':').map(Number);
2734
- const timeInTz = fromZonedTime(new Date(2000, 0, 1, hours, minutes, 0), companyTimezone);
2735
- const displayTime = formatInTimeZone(timeInTz, companyTimezone, 'h:mm a');
2736
- return (
2737
- <button
2738
- key={time}
2739
- type="button"
2740
- onClick={() => handleStartTimeSelect(time)}
2741
- className={`${styles.btnTime} private-shuttle-btn-time ${
2742
- isSelected ? styles.btnTimeSelected : styles.btnTimeDefault
2743
- }`}
2744
- >
2745
- {displayTime}
2746
- </button>
2747
- );
2748
- })}
2749
- {suggestedStartTimes.length > 0 && (
2750
- <button
2751
- type="button"
2752
- onClick={handleCustomTimeRequest}
2753
- className={`${styles.btnTime} private-shuttle-btn-time ${
2754
- isCustomTimeMode ? styles.btnTimeSelected : styles.btnTimeDefault
2755
- }`}
2756
- >
2757
- {t('booking.requestDifferentTime')}
2758
- </button>
2759
- )}
2760
- </div>
2761
- )}
2762
- {(isCustomTimeMode || suggestedStartTimes.length === 0) && (
2763
- <div className="mt-3">
2764
- <label
2765
- htmlFor="custom-pickup-time"
2766
- className={styles.labelSecondary}
2767
- >
2768
- {suggestedStartTimes.length === 0
2769
- ? t('booking.preferredPickupTime')
2770
- : t('booking.requestDifferentTime')}
2771
- </label>
2772
- <input
2773
- id="custom-pickup-time"
2774
- type="time"
2775
- value={selectedStartTime}
2776
- onChange={(e) => handleCustomTimeChange(e.target.value)}
2777
- className={styles.inputTime}
2778
- />
2779
- </div>
2780
- )}
2781
- </div>
2782
- )}
2783
-
2784
- {selectedOption && passengerCount > 0 && product.itineraryBuilder && (
2785
- <div className={`${styles.section} itinerary-builder-section`}>
2786
- <ItineraryBuilder
2787
- destinations={product.itineraryBuilder.destinations}
2788
- optionBlacklist={
2789
- privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist || []
2790
- }
2791
- selectedDestinationIds={draftItineraryDestinations}
2792
- planningNotes={draftItineraryPlanningNotes}
2793
- onDestinationsChange={setDraftItineraryDestinations}
2794
- onPlanningNotesChange={setDraftItineraryPlanningNotes}
2795
- />
2796
- {isAdmin && (
2797
- <div className={styles.adminBox}>
2798
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2799
- Add on hours (admin)
2800
- </label>
2801
- <p className="text-sm text-stone-600 mb-3">
2802
- Each additional hour adds{' '}
2803
- {formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency, locale as 'en' | 'fr')}{' '}
2804
- and extends the tour duration.
2805
- </p>
2806
- <div className="flex items-center gap-3">
2807
- <button
2808
- type="button"
2809
- onClick={() => setAdditionalHoursCount((c) => Math.max(0, c - 1))}
2810
- disabled={additionalHoursCount <= 0}
2811
- className={styles.qtyBtn}
2812
- >
2813
-
2814
- </button>
2815
- <span className="w-8 text-center font-medium tabular-nums">
2816
- {additionalHoursCount}
2817
- </span>
2818
- <button
2819
- type="button"
2820
- onClick={() => setAdditionalHoursCount((c) => c + 1)}
2821
- className={styles.qtyBtn}
2822
- >
2823
- +
2824
- </button>
2825
- <span className="text-sm text-stone-600">
2826
- {additionalHoursCount === 0
2827
- ? 'No extra hours'
2828
- : additionalHoursCount === 1
2829
- ? `+1 hour • ${formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency, locale as 'en' | 'fr')}`
2830
- : `+${additionalHoursCount} hours • ${formatCurrencyAmount(additionalHoursAmount, currency, locale as 'en' | 'fr')}`}
2831
- </span>
2832
- </div>
2833
- </div>
2834
- )}
2835
- </div>
809
+ <PrivateShuttleStartTimeSection
810
+ suggestedStartTimes={suggestedStartTimes}
811
+ selectedStartTime={selectedStartTime}
812
+ isCustomTimeMode={isCustomTimeMode}
813
+ companyTimezone={companyTimezone}
814
+ t={t}
815
+ onStartTimeSelect={handleStartTimeSelect}
816
+ onCustomTimeRequest={handleCustomTimeRequest}
817
+ onCustomTimeChange={handleCustomTimeChange}
818
+ />
2836
819
  )}
2837
820
 
2838
821
  {selectedOption && passengerCount > 0 && (
2839
- <div className={styles.section}>
2840
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2841
- Safety seats for kids
2842
- </label>
2843
- <p className="text-sm text-stone-500 mb-2">
2844
- How many child safety seats do you need?
2845
- </p>
2846
- <div className="flex items-center gap-2">
2847
- <button
2848
- type="button"
2849
- onClick={() => setChildSafetySeatsCount((c) => Math.max(0, c - 1))}
2850
- disabled={childSafetySeatsCount <= 0}
2851
- className={styles.qtyBtn}
2852
- >
2853
-
2854
- </button>
2855
- <span className="w-8 text-center font-medium tabular-nums">
2856
- {childSafetySeatsCount}
2857
- </span>
2858
- <button
2859
- type="button"
2860
- onClick={() =>
2861
- setChildSafetySeatsCount((c) =>
2862
- Math.min(passengerCount, c + 1)
2863
- )
2864
- }
2865
- disabled={childSafetySeatsCount >= passengerCount}
2866
- className={styles.qtyBtn}
2867
- >
2868
- +
2869
- </button>
2870
- </div>
2871
- </div>
822
+ <PrivateShuttleItinerarySection
823
+ itineraryBuilder={product.itineraryBuilder}
824
+ optionBlacklist={
825
+ privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist || []
826
+ }
827
+ selectedDestinationIds={draftItineraryDestinations}
828
+ planningNotes={draftItineraryPlanningNotes}
829
+ isAdmin={isAdmin}
830
+ additionalHoursCount={additionalHoursCount}
831
+ additionalHoursAmount={additionalHoursAmount}
832
+ currency={currency}
833
+ locale={locale}
834
+ onDestinationsChange={setDraftItineraryDestinations}
835
+ onPlanningNotesChange={setDraftItineraryPlanningNotes}
836
+ onAdditionalHoursChange={setAdditionalHoursCount}
837
+ />
2872
838
  )}
2873
839
 
2874
- {selectedOption && passengerCount > 0 && (
2875
- <div className={styles.section}>
2876
- <label
2877
- htmlFor="food-restrictions"
2878
- className={`${styles.sectionLabel} private-shuttle-section-label`}
2879
- >
2880
- Food restrictions
2881
- </label>
2882
- <p className="text-sm text-stone-500 mb-2">
2883
- Shuttle includes croissants, coffee, tea, hot chocolate, trail snacks.
2884
- </p>
2885
- <textarea
2886
- id="food-restrictions"
2887
- value={foodRestrictions}
2888
- onChange={(e) => setFoodRestrictions(e.target.value)}
2889
- placeholder="Any dietary restrictions or allergies?"
2890
- rows={2}
2891
- className={styles.input}
2892
- />
2893
- </div>
840
+ {selectedOption && (
841
+ <PrivateShuttlePreferencesSection
842
+ passengerCount={passengerCount}
843
+ childSafetySeatsCount={childSafetySeatsCount}
844
+ foodRestrictions={foodRestrictions}
845
+ onChildSafetySeatsCountChange={setChildSafetySeatsCount}
846
+ onFoodRestrictionsChange={setFoodRestrictions}
847
+ />
2894
848
  )}
2895
849
 
2896
- {selectedOption && passengerCount > 0 && addOns.length > 0 && (
2897
- <div className={styles.section}>
2898
- {draftItineraryDestinations.includes('emerald_lake') && (() => {
2899
- const lunchAddOn = addOns.find((a) => a.addOnId === 'addon_el_lunch');
2900
- if (!lunchAddOn || !canUseMealDrinkSelector(lunchAddOn)) return null;
2901
- return (
2902
- <MealDrinkAddOnSelector
2903
- addOn={lunchAddOn}
2904
- selections={addOnSelections}
2905
- onSelectionsChange={setAddOnSelections}
2906
- currency={currency}
2907
- locale={locale}
2908
- />
2909
- );
2910
- })()}
2911
- {(() => {
2912
- const animalsAddOn = addOns.find((a) => a.addOnId === 'addon_animals');
2913
- if (!animalsAddOn) return null;
2914
- const isAnimalsSelected = addOnSelections.some(
2915
- (s) => s.addOnId === 'addon_animals'
2916
- );
2917
- return (
2918
- <div>
2919
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2920
- Traveling with animals?
2921
- </label>
2922
- <p className="text-sm text-stone-500 mb-2">
2923
- {animalsAddOn.description ||
2924
- 'Cleaning fee for traveling with animals'}
2925
- </p>
2926
- <div className="flex flex-wrap gap-2">
2927
- <button
2928
- type="button"
2929
- onClick={() => {
2930
- setAddOnSelections((prev) =>
2931
- prev.filter((s) => s.addOnId !== 'addon_animals')
2932
- );
2933
- }}
2934
- className={`${styles.btnTime} private-shuttle-btn-time ${
2935
- !isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
2936
- }`}
2937
- >
2938
- No animals
2939
- </button>
2940
- <button
2941
- type="button"
2942
- onClick={() => {
2943
- setAddOnSelections((prev) => {
2944
- if (isAnimalsSelected) return prev;
2945
- return [...prev, { addOnId: 'addon_animals', quantity: 1 }];
2946
- });
2947
- }}
2948
- className={`${styles.btnTime} private-shuttle-btn-time flex items-center gap-2 ${
2949
- isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
2950
- }`}
2951
- >
2952
- <span>Yes, traveling with animals</span>
2953
- <span className="text-sm font-semibold opacity-90">
2954
- +
2955
- {formatCurrencyAmount(
2956
- animalsAddOn.price || 0,
2957
- currency,
2958
- locale as 'en' | 'fr'
2959
- )}
2960
- </span>
2961
- </button>
2962
- </div>
2963
- </div>
2964
- );
2965
- })()}
2966
- </div>
850
+ {selectedOption && (
851
+ <PrivateShuttleAddOnsSection
852
+ passengerCount={passengerCount}
853
+ addOns={addOns}
854
+ addOnSelections={addOnSelections}
855
+ selectedDestinationIds={draftItineraryDestinations}
856
+ currency={currency}
857
+ locale={locale}
858
+ onAddOnSelectionsChange={setAddOnSelections}
859
+ />
2967
860
  )}
2968
861
 
2969
- {selectedOption &&
2970
- passengerCount > 0 &&
2971
- product.pickupLocations &&
2972
- product.pickupLocations.length > 0 && (
2973
- <div id="pickup-location-section" className={styles.section}>
2974
- {pickupLocationId || customPickupAddress || pickupLocationSkipped ? (
2975
- <div className="space-y-4">
2976
- <div className="flex items-center justify-between">
2977
- <div>
2978
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
2979
- {t('pickup.pickupLocation') || 'Pickup Location'}
2980
- </label>
2981
- {pickupLocationSkipped ? (
2982
- <p className="text-sm text-stone-900">
2983
- {t('booking.pickupLocationUnknown')}
2984
- </p>
2985
- ) : customPickupAddress ? (
2986
- <p className="text-sm text-stone-900">{customPickupAddress}</p>
2987
- ) : (
2988
- <>
2989
- <p className="text-sm text-stone-900">
2990
- {selectedPickupLocation?.name}
2991
- </p>
2992
- <p className="text-xs text-stone-500">
2993
- {selectedPickupLocation?.address}
2994
- </p>
2995
- </>
2996
- )}
2997
- </div>
2998
- <button
2999
- type="button"
3000
- onClick={() => {
3001
- setPickupLocationId(null);
3002
- setCustomPickupAddress(null);
3003
- setPickupLocationSkipped(false);
3004
- setSelectedStartTime('');
3005
- setIsCustomTimeMode(false);
3006
- }}
3007
- className={styles.changeBtn}
3008
- >
3009
- {t('common.change') || 'Change'}
3010
- </button>
3011
- </div>
3012
- </div>
3013
- ) : (
3014
- <PickupLocationSelector
3015
- pickupLocations={product.pickupLocations}
3016
- selectedLocationId={pickupLocationId}
3017
- selectedCustomAddress={customPickupAddress}
3018
- highlightedPickupLocationIds={highlightedPickupLocationIds}
3019
- allowCustomLocation
3020
- restrictCustomLocationToServiceArea
3021
- isSkipped={pickupLocationSkipped}
3022
- destinations={product.destinations}
3023
- onLocationSelect={(locationId, customLocation) => {
3024
- setError('');
3025
- if (customLocation) {
3026
- setPickupLocationId(null);
3027
- setCustomPickupAddress(customLocation.address);
3028
- setPickupLocationSkipped(false);
3029
- } else {
3030
- setPickupLocationId(locationId);
3031
- setCustomPickupAddress(null);
3032
- setPickupLocationSkipped(false);
3033
- }
3034
- }}
3035
- onSkip={() => {
3036
- setPickupLocationSkipped(true);
3037
- setPickupLocationId(null);
3038
- setCustomPickupAddress(null);
3039
- setError('');
3040
- }}
3041
- />
3042
- )}
3043
- </div>
3044
- )}
3045
-
3046
- {selectedStartTime && passengerCount > 0 && (
3047
- <div className={`${styles.section} mt-6`}>
3048
- <PriceSummary
3049
- lines={[
3050
- {
3051
- kind: 'line',
3052
- label: resourceCount > 1 ? `Shuttle x ${resourceCount}` : 'Shuttle',
3053
- amount: basePrice,
3054
- type: 'ticket',
3055
- },
3056
- ...addOnSelections
3057
- .map((sel) => {
3058
- const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
3059
- if (!addOn) return null;
3060
- const base = addOn.price ?? 0;
3061
- const hasVariant =
3062
- (addOn.variantType === 'single_choice' ||
3063
- addOn.variantType === 'multi_quantity') &&
3064
- sel.variantId;
3065
- const adj = hasVariant
3066
- ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ??
3067
- 0)
3068
- : 0;
3069
- return {
3070
- kind: 'line' as const,
3071
- label: addOn.name,
3072
- amount: (base + adj) * (sel.quantity ?? 1),
3073
- type: 'fee' as const,
3074
- };
3075
- })
3076
- .filter(Boolean) as Array<{ kind: 'line'; label: string; amount: number; type: 'fee' }>,
3077
- ...(additionalHoursAmount > 0
3078
- ? [
3079
- {
3080
- kind: 'line' as const,
3081
- label:
3082
- additionalHoursCount === 1
3083
- ? 'Additional hour'
3084
- : `Additional hours (${additionalHoursCount})`,
3085
- amount: additionalHoursAmount,
3086
- type: 'fee' as const,
3087
- },
3088
- ]
3089
- : []),
3090
- ...perBookingFeeLineItems.map((fee) => ({
3091
- kind: 'line' as const,
3092
- label: fee.name,
3093
- amount: fee.totalAmount,
3094
- type: 'fee' as const,
3095
- tooltip: isMoraineLakeRoadAccessFeeName(fee.name)
3096
- ? MORAINE_LAKE_ROAD_ACCESS_FEE_TOOLTIP
3097
- : undefined,
3098
- })),
3099
- ...(cancellationPolicyFee > 0
3100
- ? [
3101
- {
3102
- kind: 'line' as const,
3103
- label: selectedCancellationPolicy?.label ?? 'Cancellation',
3104
- amount: cancellationPolicyFee,
3105
- type: 'cancellation' as const,
3106
- },
3107
- ]
3108
- : []),
3109
- ]}
3110
- total={depositInfo ? depositInfo.depositAmount : totalPrice}
3111
- currency={currency}
3112
- locale={locale as 'en' | 'fr'}
3113
- taxAmount={effectiveTaxAmount}
3114
- taxRate={taxRate}
3115
- discountAmount={effectivePromoDiscountAmount}
3116
- discountLabel={activePromoCode ? `Promo: ${activePromoCode}` : undefined}
3117
- extraBetweenTaxAndTotal={
3118
- isAdmin ? (
3119
- <PromoCodeInput
3120
- promoCodeInput={promoCodeInput}
3121
- appliedPromoCode={appliedPromoCode}
3122
- promoCodeError={promoCodeError}
3123
- promoCodeValidating={promoCodeValidating}
3124
- promoDiscountAmount={promoDiscountAmount}
3125
- currency={currency}
3126
- locale={locale}
3127
- t={t}
3128
- onInputChange={(value) => {
3129
- setPromoCodeInput(value);
3130
- setPromoCodeError('');
3131
- }}
3132
- onApply={handleApplyPromo}
3133
- onRemove={() => {
3134
- lastValidatedInputRef.current = null;
3135
- setAppliedPromoCode(null);
3136
- setPromoCodeInput('');
3137
- setPromoCodeError('');
3138
- setPromoDiscountAmount(0);
3139
- setIsGiftCard(false);
3140
- setIsVoucher(false);
3141
- fetchedRangesRef.current = [];
3142
- }}
3143
- />
3144
- ) : null
3145
- }
3146
- size="sm"
3147
- t={t}
3148
- depositMode={
3149
- depositInfo
3150
- ? {
3151
- totalLabel: t('booking.deposit') || 'Deposit',
3152
- balanceAmount: depositInfo.balanceAmount,
3153
- fullTotalAmount: depositInfo.totalPrice,
3154
- }
3155
- : undefined
862
+ {selectedOption && (
863
+ <PrivateShuttlePickupSection
864
+ passengerCount={passengerCount}
865
+ pickupLocations={product.pickupLocations}
866
+ destinations={product.destinations}
867
+ pickupLocationId={pickupLocationId}
868
+ customPickupAddress={customPickupAddress}
869
+ pickupLocationSkipped={pickupLocationSkipped}
870
+ selectedPickupLocation={selectedPickupLocation}
871
+ highlightedPickupLocationIds={highlightedPickupLocationIds}
872
+ t={t}
873
+ onLocationSelect={(locationId, customAddress) => {
874
+ setError('');
875
+ if (customAddress) {
876
+ setPickupLocationId(null);
877
+ setCustomPickupAddress(customAddress);
878
+ setPickupLocationSkipped(false);
879
+ } else {
880
+ setPickupLocationId(locationId);
881
+ setCustomPickupAddress(null);
882
+ setPickupLocationSkipped(false);
3156
883
  }
3157
- hideSubtotal={!!depositInfo}
3158
- />
3159
- {depositInfo && (
3160
- <div className="deposit-notice mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
3161
- <p className="text-xs font-medium">
3162
- {(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice'
3163
- ? t('booking.depositPaymentNotice')
3164
- : null) ?? "You're paying the deposit today."}
3165
- </p>
3166
- <p className="mt-1 text-xs text-amber-700/90">
3167
- {privateShuttleConfig?.balanceChargeDaysBefore && privateShuttleConfig.balanceChargeDaysBefore > 0
3168
- ? (t('booking.balanceChargeNotice', { days: privateShuttleConfig.balanceChargeDaysBefore }) !== 'booking.balanceChargeNotice'
3169
- ? t('booking.balanceChargeNotice', { days: privateShuttleConfig.balanceChargeDaysBefore })
3170
- : `The remaining balance will be charged ${privateShuttleConfig.balanceChargeDaysBefore} days before your booking. You can also pay it earlier from your Manage Booking page.`)
3171
- : (t('booking.balancePayEarlier') !== 'booking.balancePayEarlier'
3172
- ? t('booking.balancePayEarlier')
3173
- : 'You can pay the remaining balance anytime from your Manage Booking page.')}
3174
- </p>
3175
- </div>
3176
- )}
3177
- </div>
884
+ }}
885
+ onSkip={() => {
886
+ setPickupLocationSkipped(true);
887
+ setPickupLocationId(null);
888
+ setCustomPickupAddress(null);
889
+ setError('');
890
+ }}
891
+ onReset={() => {
892
+ setPickupLocationId(null);
893
+ setCustomPickupAddress(null);
894
+ setPickupLocationSkipped(false);
895
+ setSelectedStartTime('');
896
+ setIsCustomTimeMode(false);
897
+ }}
898
+ />
3178
899
  )}
3179
900
 
3180
901
  {selectedStartTime && passengerCount > 0 && (
3181
- <div className="border-t border-stone-200 pt-6 space-y-4">
3182
- <div className="space-y-4">
3183
- <>
3184
- <div className={styles.contactGrid}>
3185
- <div>
3186
- <label className={styles.sectionLabel}>
3187
- {t('booking.firstName')} <span className={styles.required}>*</span>
3188
- </label>
3189
- <input
3190
- type="text"
3191
- value={firstName}
3192
- onChange={(e) => {
3193
- setFirstName(e.target.value);
3194
- setError('');
3195
- }}
3196
- placeholder={t('booking.firstNamePlaceholder')}
3197
- className={styles.input}
3198
- />
3199
- </div>
3200
- <div>
3201
- <label className={styles.sectionLabel}>
3202
- {t('booking.lastName')} <span className={styles.required}>*</span>
3203
- </label>
3204
- <input
3205
- type="text"
3206
- value={lastName}
3207
- onChange={(e) => {
3208
- setLastName(e.target.value);
3209
- setError('');
3210
- }}
3211
- placeholder={t('booking.lastNamePlaceholder')}
3212
- className={styles.input}
3213
- />
3214
- </div>
3215
- </div>
3216
- <div>
3217
- <label className={styles.sectionLabel}>
3218
- {t('common.emailForConfirmation')} <span className={styles.required}>*</span>
3219
- </label>
3220
- <input
3221
- type="email"
3222
- value={email}
3223
- onChange={(e) => {
3224
- setEmail(e.target.value);
3225
- setError('');
3226
- }}
3227
- placeholder={t('common.emailPlaceholder')}
3228
- className={styles.input}
3229
- />
3230
- </div>
3231
- <CheckoutOptionalPhoneFields
3232
- idPrefix="shuttle"
3233
- labelClassName={styles.sectionLabel}
3234
- phoneNumber={phoneNumber}
3235
- onPhoneNumberChange={(v) => {
3236
- setPhoneNumber(v);
3237
- setError('');
3238
- }}
3239
- t={t}
3240
- />
3241
- </>
3242
- {pricingConfig?.cancellationPolicies &&
3243
- pricingConfig.cancellationPolicies.length > 0 && (
3244
- <div>
3245
- <CancellationPolicySelector
3246
- policies={[...pricingConfig.cancellationPolicies].sort((a, b) => {
3247
- const feeA = a.feeByCurrency[currency] ?? 0;
3248
- const feeB = b.feeByCurrency[currency] ?? 0;
3249
- return feeA - feeB;
3250
- })}
3251
- selectedPolicyId={cancellationPolicyId}
3252
- currency={currency}
3253
- locale={locale}
3254
- t={t}
3255
- onPolicySelect={setCancellationPolicyId}
3256
- rowSubtitle={
3257
- <>
3258
- <b>Your deposit today is 100% refundable</b> — only once we align on an itinerary that you approve, will your deposit be locked in.
3259
- </>
3260
- }
3261
- />
3262
- </div>
3263
- )}
3264
- {isAdmin && (
3265
- <div className="space-y-2 p-4 bg-amber-50/50 rounded-lg">
3266
- <label className="flex items-center gap-2">
3267
- <input
3268
- type="checkbox"
3269
- checked={skipConfirmationCommunications}
3270
- onChange={(e) => setSkipConfirmationCommunications(e.target.checked)}
3271
- />
3272
- <span className="text-sm">
3273
- Don&apos;t send confirmation for this booking
3274
- </span>
3275
- </label>
3276
- <label className="flex items-center gap-2">
3277
- <input
3278
- type="checkbox"
3279
- checked={disableAutoCommunications}
3280
- onChange={(e) => setDisableAutoCommunications(e.target.checked)}
3281
- />
3282
- <span className="text-sm">Disable all auto communications</span>
3283
- </label>
3284
- </div>
3285
- )}
3286
- <div className="mt-4 p-4 bg-stone-50 rounded-lg border-2 border-stone-300">
3287
- <TermsAcceptance
3288
- checked={termsAccepted}
3289
- onChange={(checked) => {
3290
- setTermsAccepted(checked);
3291
- setTermsAcceptedAt(checked ? new Date().toISOString() : null);
3292
- }}
3293
- t={t}
3294
- />
3295
- </div>
3296
- </div>
3297
- {error && (
3298
- <div className={styles.errorBox}>
3299
- {error}
3300
- </div>
3301
- )}
3302
- {flowUi?.partnerAttributionSummary ? (
3303
- <div className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
3304
- <p className="m-0 text-sm text-blue-900">{flowUi.partnerAttributionSummary}</p>
3305
- {flowUi.partnerAttributionConfirmLabel ? (
3306
- <label className="mt-2 flex cursor-pointer items-start gap-2">
3307
- <input
3308
- type="checkbox"
3309
- checked={partnerAttributionConfirmed}
3310
- onChange={(e) => setPartnerAttributionConfirmed(e.target.checked)}
3311
- />
3312
- <span className="text-xs font-medium leading-5 text-blue-800">
3313
- {flowUi.partnerAttributionConfirmLabel}
3314
- </span>
3315
- </label>
3316
- ) : null}
3317
- </div>
3318
- ) : null}
3319
- {!showCheckoutModal && !showAdminPaymentChoice ? (
3320
- <>
3321
- <button
3322
- type="button"
3323
- onClick={handleCheckout}
3324
- disabled={
3325
- loading ||
3326
- !termsAccepted ||
3327
- Boolean(flowUi?.partnerAttributionConfirmLabel && !partnerAttributionConfirmed)
3328
- }
3329
- className={styles.submitBtn}
3330
- >
3331
- {loading
3332
- ? isProviderChangeMode
3333
- ? 'Processing…'
3334
- : (t('booking.creatingReservation') || 'Creating reservation...')
3335
- : isProviderChangeMode
3336
- ? `Change booking (${formatCurrencyAmount(depositInfo ? depositInfo.depositAmount : totalPrice, currency, locale as 'en' | 'fr')})`
3337
- : `${flowUi?.partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formatCurrencyAmount(depositInfo ? depositInfo.depositAmount : totalPrice, currency, locale as 'en' | 'fr')})`}
3338
- </button>
3339
- <p className={styles.secureNote}>
3340
- {t('booking.securePayment') || 'Secure payment powered by Stripe'}
3341
- </p>
3342
- </>
3343
- ) : null}
3344
- </div>
902
+ <PrivateShuttleCheckoutSection
903
+ currency={currency}
904
+ locale={locale}
905
+ t={t}
906
+ isAdmin={isAdmin}
907
+ isProviderChangeMode={isProviderChangeMode}
908
+ priceSummaryLines={priceSummaryLines}
909
+ depositInfo={depositInfo}
910
+ balanceChargeDaysBefore={privateShuttleConfig?.balanceChargeDaysBefore}
911
+ totalPrice={totalPrice}
912
+ effectiveTaxAmount={effectiveTaxAmount}
913
+ taxRate={taxRate}
914
+ effectivePromoDiscountAmount={effectivePromoDiscountAmount}
915
+ activePromoCode={activePromoCode}
916
+ promoCodeInput={promoCodeInput}
917
+ appliedPromoCode={appliedPromoCode}
918
+ promoCodeError={promoCodeError}
919
+ promoCodeValidating={promoCodeValidating}
920
+ promoDiscountAmount={promoDiscountAmount}
921
+ onPromoInputChange={handlePromoInputChange}
922
+ onPromoApply={handleApplyPromo}
923
+ onPromoRemove={handleRemovePromoAndDiscount}
924
+ firstName={firstName}
925
+ lastName={lastName}
926
+ email={email}
927
+ phoneNumber={phoneNumber}
928
+ onFirstNameChange={(value) => {
929
+ setFirstName(value);
930
+ setError('');
931
+ }}
932
+ onLastNameChange={(value) => {
933
+ setLastName(value);
934
+ setError('');
935
+ }}
936
+ onEmailChange={(value) => {
937
+ setEmail(value);
938
+ setError('');
939
+ }}
940
+ onPhoneNumberChange={(value) => {
941
+ setPhoneNumber(value);
942
+ setError('');
943
+ }}
944
+ cancellationPolicies={pricingConfig?.cancellationPolicies}
945
+ cancellationPolicyId={cancellationPolicyId}
946
+ onCancellationPolicySelect={setCancellationPolicyId}
947
+ skipConfirmationCommunications={skipConfirmationCommunications}
948
+ disableAutoCommunications={disableAutoCommunications}
949
+ onSkipConfirmationChange={setSkipConfirmationCommunications}
950
+ onDisableCommunicationsChange={setDisableAutoCommunications}
951
+ termsAccepted={termsAccepted}
952
+ onTermsAcceptedChange={handleTermsAcceptedChange}
953
+ error={error}
954
+ partnerAttributionSummary={flowUi?.partnerAttributionSummary}
955
+ partnerAttributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
956
+ partnerAttributionConfirmed={partnerAttributionConfirmed}
957
+ onPartnerAttributionConfirmedChange={setPartnerAttributionConfirmed}
958
+ showCheckoutModal={showCheckoutModal}
959
+ showAdminPaymentChoice={showAdminPaymentChoice}
960
+ loading={loading}
961
+ onCheckout={handleCheckout}
962
+ partnerDeferredInvoiceSubmitLabel={flowUi?.partnerDeferredInvoiceSubmitLabel}
963
+ />
3345
964
  )}
3346
965
  </div>
3347
966
  </>