@ticketboothapp/booking 1.2.122 → 1.2.124
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.
- package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +647 -6157
- package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
- package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
- package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
- package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
- package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
- package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
- package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
- package/src/components/booking/CheckoutModal.tsx +68 -49
- package/src/components/booking/NewBookingFlow.tsx +485 -3505
- package/src/components/booking/PriceBreakdown.tsx +17 -3
- package/src/components/booking/PriceSummary.tsx +21 -3
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
- package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
- package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
- package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
- package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
- package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
- package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
- package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
- package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
- package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
- package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
- package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
- package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
- package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
- package/src/components/booking/admin-change-provider-payload.ts +101 -0
- package/src/components/booking/change-booking-checkout-builders.ts +185 -0
- package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
- package/src/components/booking/change-booking-flow-helpers.ts +706 -0
- package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
- package/src/components/booking/change-booking-quote-guards.ts +223 -0
- package/src/components/booking/change-booking-quote-state.ts +64 -0
- package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
- package/src/components/booking/private-shuttle-availability.ts +111 -0
- package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
- package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
- package/src/components/booking/standard-booking-availability.ts +305 -0
- package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
- package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
- package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
- package/src/components/booking/use-private-shuttle-availability.ts +602 -0
- package/src/components/booking/use-standard-booking-availability.ts +806 -0
- package/src/components/booking/useActiveProductOptions.ts +26 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
- package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
- package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
- package/src/components/booking/useAdminChangeProductReset.ts +60 -0
- package/src/components/booking/useAdminChangeProducts.ts +102 -0
- package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
- package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
- package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
- package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
- package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
- package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
- package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
- package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
- package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
- package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
- package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
- package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
- package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
- package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
- package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
- package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
- package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
- package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
- package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
- package/src/components/booking/useChangeBookingSelection.ts +362 -0
- package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
- package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
- package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
- package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
- package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
- package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
- package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
- package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
- package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
- package/src/components/booking/useStandardBookingPromoController.ts +258 -0
- package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
- package/src/data/products-config.json +1 -1
- package/src/lib/booking/change-booking-server-preview.ts +134 -20
- package/src/lib/booking/change-flow-pricing.ts +31 -11
- package/src/lib/booking/checkout-breakdown.ts +66 -1
- package/src/lib/booking/pricing-config.ts +10 -0
- package/src/lib/booking/source-metadata.ts +58 -0
- package/src/lib/booking-api.ts +154 -3
- package/src/lib/pricing-v2-shadow.ts +272 -0
- package/test/change-booking-helpers.test.ts +206 -0
|
@@ -1,443 +1,51 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
|
-
import { parseISO
|
|
4
|
+
import { parseISO } from 'date-fns';
|
|
5
5
|
import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
|
|
6
6
|
import {
|
|
7
|
-
getAvailabilities,
|
|
8
|
-
createReservation,
|
|
9
|
-
cancelReservation,
|
|
10
|
-
cancelReservationBestEffort,
|
|
11
|
-
createPaymentIntent,
|
|
12
|
-
confirmFreeBooking,
|
|
13
|
-
confirmBookingWithoutPayment,
|
|
14
|
-
confirmPartnerBookingWithoutPayment,
|
|
15
|
-
getAddOns,
|
|
16
|
-
validatePromoCode,
|
|
17
|
-
getPromoDiscount,
|
|
18
|
-
type Product,
|
|
19
7
|
type Availability,
|
|
20
|
-
type ReturnOption,
|
|
21
|
-
type AddOn,
|
|
22
|
-
isInsufficientCapacityReserveError,
|
|
23
|
-
describeStandardTourCapacityConflictMessage,
|
|
24
|
-
reportReserveCapacityConflictClientContext,
|
|
25
8
|
} from '../../lib/booking-api';
|
|
26
9
|
import {
|
|
27
10
|
EARLIEST_AVAILABILITY_DATE,
|
|
28
|
-
LATEST_AVAILABILITY_DATE,
|
|
29
|
-
INITIAL_FETCH_WEEKS,
|
|
30
|
-
VISIBLE_RANGE_BUFFER_WEEKS,
|
|
31
11
|
} from '../../lib/booking-constants';
|
|
32
12
|
import { Calendar } from './Calendar';
|
|
33
|
-
import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
|
|
34
|
-
import { ItineraryBox } from './ItineraryBox';
|
|
35
|
-
import { ItineraryPlaceholder } from './ItineraryPlaceholder';
|
|
36
|
-
import { PickupTimeSelector } from './PickupTimeSelector';
|
|
37
|
-
import { ReturnTimeSelector } from './ReturnTimeSelector';
|
|
38
|
-
import { CancellationPolicySelector } from './CancellationPolicySelector';
|
|
39
|
-
import { TicketSelector } from './TicketSelector';
|
|
40
|
-
import { AddOnsSection } from './AddOnsSection';
|
|
41
|
-
import { CheckoutForm } from './CheckoutForm';
|
|
42
|
-
import { PromoCodeInput } from './PromoCodeInput';
|
|
43
13
|
import { useTranslations, useLocale } from '../../lib/booking/i18n';
|
|
44
|
-
import { type Currency } from './CurrencySwitcher';
|
|
45
|
-
import { formatBookingRefForDisplay } from '../../lib/booking-ref';
|
|
46
|
-
import { formatCurrencyAmount } from '../../lib/currency';
|
|
47
|
-
import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
|
|
48
|
-
import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep, GetAvailabilitiesResponse } from '../../lib/booking-api';
|
|
49
|
-
import { ItineraryStepType as StepType } from '../../lib/booking-api';
|
|
50
|
-
import {
|
|
51
|
-
getDisplayPriceFromBaseInDisplayCurrency,
|
|
52
|
-
computePriceBreakdown,
|
|
53
|
-
computeOrderSummary,
|
|
54
|
-
type PriceBreakdown as PriceBreakdownData,
|
|
55
|
-
type OrderSummary,
|
|
56
|
-
type OrderSummaryFeeLine,
|
|
57
|
-
} from '../../lib/booking/pricing';
|
|
58
14
|
import { useCompanyTimezone } from '../../contexts/CompanyContext';
|
|
59
15
|
import { useBookingApp } from '../../contexts/BookingAppContext';
|
|
60
|
-
import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
|
|
61
|
-
import { type PriceSummaryLine } from './PriceSummary';
|
|
62
|
-
import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
|
|
63
|
-
import { BookingFlowCollage } from './BookingFlowCollage';
|
|
64
|
-
import { TourDescription } from './TourDescription';
|
|
65
|
-
import {
|
|
66
|
-
buildBookingSourceContext,
|
|
67
|
-
inferClientBookingSourceFromProductIds,
|
|
68
|
-
type BookingSourceMetadata,
|
|
69
|
-
} from '../../lib/booking/source-metadata';
|
|
70
|
-
import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
|
|
71
16
|
import { useBookingHost } from '../../runtime';
|
|
72
|
-
import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
73
17
|
import type { NewBookingFlowProps } from './booking-flow-types';
|
|
74
|
-
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
75
|
-
import { optionalCheckoutContactFields } from '../../lib/booking/checkout-contact';
|
|
76
18
|
import {
|
|
77
|
-
filterAvailabilitiesAfterPublicBookingCutoff,
|
|
78
|
-
isAvailabilityAfterPublicBookingCutoff,
|
|
79
19
|
isAvailabilityWithinPublicPickupUnknownCutoff,
|
|
80
|
-
isDateAfterPublicBookingCutoff,
|
|
81
|
-
parseBookingCutoffDateTime,
|
|
82
20
|
} from '../../lib/booking/booking-cutoffs';
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (/\bCHILD(REN)?\b/.test(compact)) return 'CHILD';
|
|
108
|
-
if (/\bINFANTS?\b/.test(compact)) return 'INFANT';
|
|
109
|
-
if (/\bSENIORS?\b/.test(compact)) return 'SENIOR';
|
|
110
|
-
if (/\bSTUDENTS?\b/.test(compact)) return 'STUDENT';
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function extractTrailingQty(label: string): { baseLabel: string; qty: number } {
|
|
115
|
-
const trimmed = label.trim();
|
|
116
|
-
const m = trimmed.match(/^(.*?)(?:\s*[×x]\s*(\d+))$/);
|
|
117
|
-
if (!m) return { baseLabel: trimmed, qty: 1 };
|
|
118
|
-
const baseLabel = (m[1] || '').trim();
|
|
119
|
-
const qty = Math.max(1, Number(m[2]) || 1);
|
|
120
|
-
return { baseLabel, qty };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function deriveAddOnSelectionsFromReceiptLines(
|
|
124
|
-
addOns: AddOn[],
|
|
125
|
-
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
126
|
-
): Array<{ addOnId: string; variantId?: string; quantity?: number }> {
|
|
127
|
-
const selections: Array<{ addOnId: string; variantId?: string; quantity?: number }> = [];
|
|
128
|
-
for (const line of lines) {
|
|
129
|
-
const type = (line.type || '').trim().toUpperCase();
|
|
130
|
-
if (type !== 'FEE') continue;
|
|
131
|
-
const amount = Number(line.amount ?? 0);
|
|
132
|
-
if (!Number.isFinite(amount) || amount <= 0) continue;
|
|
133
|
-
const rawLabel = (line.label || '').trim();
|
|
134
|
-
if (!rawLabel) continue;
|
|
135
|
-
|
|
136
|
-
const { baseLabel, qty } = extractTrailingQty(rawLabel);
|
|
137
|
-
let matched = false;
|
|
138
|
-
|
|
139
|
-
for (const addOn of addOns) {
|
|
140
|
-
if (matched) break;
|
|
141
|
-
const addOnName = addOn.name?.trim();
|
|
142
|
-
if (!addOnName) continue;
|
|
143
|
-
|
|
144
|
-
if (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') {
|
|
145
|
-
for (const variant of addOn.variants ?? []) {
|
|
146
|
-
const variantLabel = variant.label?.trim();
|
|
147
|
-
if (!variantLabel) continue;
|
|
148
|
-
if (baseLabel === `${addOnName} (${variantLabel})`) {
|
|
149
|
-
selections.push({ addOnId: addOn.addOnId, variantId: variant.id, quantity: qty });
|
|
150
|
-
matched = true;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
} else if (baseLabel === addOnName) {
|
|
155
|
-
selections.push({ addOnId: addOn.addOnId, quantity: qty });
|
|
156
|
-
matched = true;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return selections;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function findMergedAvailabilityForSelection(
|
|
164
|
-
merged: Availability[],
|
|
165
|
-
selected: Availability | null
|
|
166
|
-
): Availability | undefined {
|
|
167
|
-
if (!selected) return undefined;
|
|
168
|
-
const optId = getAvailabilityOptionId(selected) || undefined;
|
|
169
|
-
const dt = selected.dateTime;
|
|
170
|
-
const availId = selected.availabilityId?.trim();
|
|
171
|
-
if (availId && optId) {
|
|
172
|
-
const exact = merged.find(
|
|
173
|
-
(a) =>
|
|
174
|
-
a.availabilityId === availId &&
|
|
175
|
-
getAvailabilityOptionId(a) === optId
|
|
176
|
-
);
|
|
177
|
-
if (exact) return exact;
|
|
178
|
-
}
|
|
179
|
-
return merged.find((a) => a.dateTime === dt && getAvailabilityOptionId(a) === optId);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function getAvailabilityOptionId(availability: Availability, fallbackOptionId?: string): string {
|
|
183
|
-
const productOptionId = availability.productOptionId?.trim();
|
|
184
|
-
if (productOptionId) return productOptionId;
|
|
185
|
-
const productId = availability.productId?.trim();
|
|
186
|
-
if (productId?.startsWith('po_')) return productId;
|
|
187
|
-
return fallbackOptionId?.trim() ?? '';
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function availabilityWithOptionId(availability: Availability, fallbackOptionId?: string): Availability {
|
|
191
|
-
const productOptionId = getAvailabilityOptionId(availability, fallbackOptionId);
|
|
192
|
-
return productOptionId ? { ...availability, productOptionId } : availability;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Prefer full availability rows over calendar summary rows when merging fetches. */
|
|
196
|
-
function mergeAvailabilityRecord(
|
|
197
|
-
existing: Availability | undefined,
|
|
198
|
-
incoming: Availability,
|
|
199
|
-
): Availability {
|
|
200
|
-
if (!existing) return incoming;
|
|
201
|
-
const existingIsSummary = existing.isSummary === true;
|
|
202
|
-
const incomingIsSummary = incoming.isSummary === true;
|
|
203
|
-
if (existingIsSummary && !incomingIsSummary) return incoming;
|
|
204
|
-
if (!existingIsSummary && incomingIsSummary) return existing;
|
|
205
|
-
return incoming;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function mergeAvailabilitiesIntoMap(
|
|
209
|
-
map: Map<string, Availability>,
|
|
210
|
-
availabilities: Availability[],
|
|
211
|
-
): void {
|
|
212
|
-
for (const availability of availabilities) {
|
|
213
|
-
const key = getAvailabilityCacheKey(availability);
|
|
214
|
-
map.set(key, mergeAvailabilityRecord(map.get(key), availability));
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function getAvailabilityCacheKey(availability: Availability): string {
|
|
219
|
-
return `${availability.dateTime}-${getAvailabilityOptionId(availability)}`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function pickDefaultAvailabilityForTimes(
|
|
223
|
-
times: Availability[],
|
|
224
|
-
activeOptions: Product['options'],
|
|
225
|
-
): Availability | null {
|
|
226
|
-
if (times.length === 0) return null;
|
|
227
|
-
const mostPopularOption = activeOptions.find((opt) => opt.mostPopular);
|
|
228
|
-
const candidate = mostPopularOption
|
|
229
|
-
? times.find(
|
|
230
|
-
(avail) => getAvailabilityOptionId(avail) === mostPopularOption.optionId && avail.vacancies > 0,
|
|
231
|
-
)
|
|
232
|
-
: null;
|
|
233
|
-
const fallback = times.find((avail) => avail.vacancies > 0);
|
|
234
|
-
return candidate ?? fallback ?? null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function availabilityMatchesSelectedDate(
|
|
238
|
-
availability: Availability,
|
|
239
|
-
selectedDate: string,
|
|
240
|
-
companyTimezone: string,
|
|
241
|
-
): boolean {
|
|
242
|
-
try {
|
|
243
|
-
const dateInCompanyTz = formatInTimeZone(
|
|
244
|
-
parseAvailabilityDateTime(availability.dateTime),
|
|
245
|
-
companyTimezone,
|
|
246
|
-
'yyyy-MM-dd',
|
|
247
|
-
);
|
|
248
|
-
return dateInCompanyTz === selectedDate;
|
|
249
|
-
} catch {
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function shouldSyncSelectedAvailability(
|
|
255
|
-
current: Availability,
|
|
256
|
-
updated: Availability,
|
|
257
|
-
): boolean {
|
|
258
|
-
return (
|
|
259
|
-
current !== updated &&
|
|
260
|
-
(current.isSummary !== updated.isSummary ||
|
|
261
|
-
current.vacancies !== updated.vacancies ||
|
|
262
|
-
current.availabilityId !== updated.availabilityId ||
|
|
263
|
-
(current.returnOptions?.length ?? 0) !== (updated.returnOptions?.length ?? 0))
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function mergePrecomputedPricesFromAvailabilityResult(
|
|
268
|
-
next: Record<string, PrecomputedPricesByCategory>,
|
|
269
|
-
result: GetAvailabilitiesResponse,
|
|
270
|
-
fallbackOptionId?: string,
|
|
271
|
-
) {
|
|
272
|
-
if (result.precomputedPricesByOption) {
|
|
273
|
-
Object.entries(result.precomputedPricesByOption).forEach(([optionId, prices]) => {
|
|
274
|
-
if (prices && Object.keys(prices).length > 0) next[optionId] = prices;
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
if (fallbackOptionId && result.precomputedPrices && Object.keys(result.precomputedPrices).length > 0) {
|
|
278
|
-
next[fallbackOptionId] = result.precomputedPrices;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
|
|
283
|
-
function buildPricingFromAvailability(
|
|
284
|
-
selectedAvailability: Availability | null,
|
|
285
|
-
activeOptions: Product['options'],
|
|
286
|
-
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
287
|
-
currency: Currency,
|
|
288
|
-
pricingConfig: PricingConfig | null,
|
|
289
|
-
hasFees: boolean,
|
|
290
|
-
isSimplifiedPricingView: boolean,
|
|
291
|
-
) {
|
|
292
|
-
if (!selectedAvailability || !pricingConfig) return [];
|
|
293
|
-
const optionId = getAvailabilityOptionId(selectedAvailability);
|
|
294
|
-
const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
|
|
295
|
-
const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
|
|
296
|
-
const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
|
|
297
|
-
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
298
|
-
const getBaseInDisplayCurrency = (category: string) => {
|
|
299
|
-
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
300
|
-
if (fromPrecomputed != null) return fromPrecomputed;
|
|
301
|
-
return 0;
|
|
302
|
-
};
|
|
303
|
-
const buildRate = (
|
|
304
|
-
category: string,
|
|
305
|
-
backendPriceCAD: number,
|
|
306
|
-
backendInDisplayCurrency: number,
|
|
307
|
-
baseInDisplayCurrency: number,
|
|
308
|
-
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
|
|
309
|
-
) => {
|
|
310
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
311
|
-
const hasOptionScopedPrice = baseInDisplayCurrency > 0;
|
|
312
|
-
const priceCADForBreakdown =
|
|
313
|
-
hasOptionScopedPrice && currency === 'CAD' ? baseInDisplayCurrency : backendPriceCAD;
|
|
314
|
-
const displayCurrencyPriceForFallback = hasOptionScopedPrice
|
|
315
|
-
? baseInDisplayCurrency
|
|
316
|
-
: backendInDisplayCurrency;
|
|
317
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
318
|
-
const breakdown = computePriceBreakdown(
|
|
319
|
-
pricingConfig,
|
|
320
|
-
currency,
|
|
321
|
-
priceCADForBreakdown,
|
|
322
|
-
basePriceCAD,
|
|
323
|
-
hasFees,
|
|
324
|
-
appliedAdjustments,
|
|
325
|
-
undefined,
|
|
326
|
-
baseInDisplayCurrency,
|
|
327
|
-
isPublicMode,
|
|
328
|
-
);
|
|
329
|
-
const price = breakdown?.finalPrice ?? rateToDisplayPrice(displayCurrencyPriceForFallback);
|
|
330
|
-
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: priceCADForBreakdown };
|
|
331
|
-
};
|
|
332
|
-
return (
|
|
333
|
-
selectedAvailability.rates?.map((rate) => {
|
|
334
|
-
const backendPriceCAD = rate.price ?? 0;
|
|
335
|
-
const backendInDisplayCurrency =
|
|
336
|
-
rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
337
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
338
|
-
const built = buildRate(
|
|
339
|
-
rate.category,
|
|
340
|
-
backendPriceCAD,
|
|
341
|
-
backendInDisplayCurrency,
|
|
342
|
-
baseInDisplayCurrency,
|
|
343
|
-
rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
|
|
344
|
-
);
|
|
345
|
-
return {
|
|
346
|
-
category: rate.category,
|
|
347
|
-
rateId: rate.rateId || rate.category,
|
|
348
|
-
available: rate.available,
|
|
349
|
-
price: built.price,
|
|
350
|
-
priceCAD: built.priceCAD,
|
|
351
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
352
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
353
|
-
};
|
|
354
|
-
}) ||
|
|
355
|
-
selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
|
|
356
|
-
const priceCADFromApi = p.price / 100;
|
|
357
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
358
|
-
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
359
|
-
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
360
|
-
return {
|
|
361
|
-
category: p.category,
|
|
362
|
-
rateId: p.category,
|
|
363
|
-
available: selectedAvailability.vacancies,
|
|
364
|
-
price: built.price,
|
|
365
|
-
priceCAD: built.priceCAD,
|
|
366
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
367
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
368
|
-
};
|
|
369
|
-
}) ||
|
|
370
|
-
[]
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function findMergedReturnVacancies(
|
|
375
|
-
outbound: Availability | undefined,
|
|
376
|
-
selectedReturn: ReturnOption | null | undefined
|
|
377
|
-
): number | null {
|
|
378
|
-
if (!selectedReturn?.returnAvailabilityId || !outbound?.returnOptions?.length) return null;
|
|
379
|
-
const ro = outbound.returnOptions.find(
|
|
380
|
-
(r) => r.returnAvailabilityId === selectedReturn.returnAvailabilityId
|
|
381
|
-
);
|
|
382
|
-
return ro ? ro.vacancies : null;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function normalizeAddOnSelections(
|
|
386
|
-
selections: Array<{ addOnId: string; variantId?: string; quantity?: number }>
|
|
387
|
-
): Array<{ addOnId: string; variantId?: string; quantity?: number }> {
|
|
388
|
-
const qtyByKey = new Map<string, number>();
|
|
389
|
-
for (const sel of selections) {
|
|
390
|
-
const addOnId = sel.addOnId?.trim();
|
|
391
|
-
if (!addOnId) continue;
|
|
392
|
-
const variantId = sel.variantId?.trim() || '';
|
|
393
|
-
const key = `${addOnId}::${variantId}`;
|
|
394
|
-
qtyByKey.set(key, (qtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
395
|
-
}
|
|
396
|
-
return Array.from(qtyByKey.entries()).map(([key, qty]) => {
|
|
397
|
-
const [addOnId, variantIdRaw] = key.split('::');
|
|
398
|
-
const variantId = variantIdRaw?.trim() || undefined;
|
|
399
|
-
return { addOnId, variantId, quantity: qty };
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function parseAvailabilityDateTime(value: string): Date {
|
|
404
|
-
// If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
|
|
405
|
-
const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
|
|
406
|
-
return parseISO(hasExplicitOffset ? value : `${value}Z`);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function getInitialAvailabilityRangeStart(companyTimezone: string): Date {
|
|
410
|
-
const todayStr = formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd');
|
|
411
|
-
const seasonStartStr = formatInTimeZone(EARLIEST_AVAILABILITY_DATE, companyTimezone, 'yyyy-MM-dd');
|
|
412
|
-
const seasonEndStr = formatInTimeZone(LATEST_AVAILABILITY_DATE, companyTimezone, 'yyyy-MM-dd');
|
|
413
|
-
const initialDateStr =
|
|
414
|
-
todayStr < seasonStartStr
|
|
415
|
-
? seasonStartStr
|
|
416
|
-
: todayStr > seasonEndStr
|
|
417
|
-
? seasonStartStr
|
|
418
|
-
: todayStr;
|
|
419
|
-
|
|
420
|
-
return fromZonedTime(parseISO(`${initialDateStr}T00:00:00.000`), companyTimezone);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function deriveDefaultQuantitiesFromAvailabilities(
|
|
424
|
-
availabilities: Availability[]
|
|
425
|
-
): Record<string, number> | null {
|
|
426
|
-
const firstWithRates = availabilities.find((avail) => avail.rates && avail.rates.length > 0);
|
|
427
|
-
if (!firstWithRates?.rates?.length) return null;
|
|
428
|
-
|
|
429
|
-
const initialQuantities: Record<string, number> = {};
|
|
430
|
-
firstWithRates.rates.forEach((rate) => {
|
|
431
|
-
initialQuantities[rate.category] = rate.category === 'ADULT' ? 1 : 0;
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
return Object.keys(initialQuantities).length > 0 ? initialQuantities : null;
|
|
435
|
-
}
|
|
21
|
+
import {
|
|
22
|
+
availabilityMatchesSelectedDate,
|
|
23
|
+
getAvailabilityOptionId,
|
|
24
|
+
normalizeAddOnSelections,
|
|
25
|
+
parseAvailabilityDateTime,
|
|
26
|
+
pickDefaultAvailabilityForTimes,
|
|
27
|
+
} from './standard-booking-availability';
|
|
28
|
+
import { useStandardBookingAvailability } from './use-standard-booking-availability';
|
|
29
|
+
import { useStandardBookingAutoSelections } from './useStandardBookingAutoSelections';
|
|
30
|
+
import {
|
|
31
|
+
useStandardBookingCheckoutController,
|
|
32
|
+
} from './standard-booking-checkout-controller';
|
|
33
|
+
import { useStandardBookingPriceSummary } from './useStandardBookingPriceSummary';
|
|
34
|
+
import { useStandardBookingPromoController } from './useStandardBookingPromoController';
|
|
35
|
+
import { useStandardBookingPickupItinerary } from './useStandardBookingPickupItinerary';
|
|
36
|
+
import { useChangeBookingItineraryUiState } from './useChangeBookingItineraryUiState';
|
|
37
|
+
import { useBookingAvailabilityAddOns } from './useBookingAvailabilityAddOns';
|
|
38
|
+
import { useBookingQuantityCapTrim } from './useBookingQuantityCapTrim';
|
|
39
|
+
import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
|
|
40
|
+
import { StandardBookingCheckoutSection } from './StandardBookingCheckoutSection';
|
|
41
|
+
import { StandardBookingCheckoutDialogs } from './StandardBookingCheckoutDialogs';
|
|
42
|
+
import { StandardBookingItineraryPanel } from './StandardBookingItineraryPanel';
|
|
43
|
+
import { StandardBookingMediaIntro } from './StandardBookingMediaIntro';
|
|
44
|
+
import { StandardBookingSelectionControlsPanel } from './StandardBookingSelectionControlsPanel';
|
|
436
45
|
|
|
437
46
|
export function NewBookingFlow({
|
|
438
47
|
product,
|
|
439
48
|
productId,
|
|
440
|
-
onBack,
|
|
441
49
|
currency,
|
|
442
50
|
contentRef,
|
|
443
51
|
onSuccess,
|
|
@@ -461,7 +69,7 @@ export function NewBookingFlow({
|
|
|
461
69
|
availabilityPricingProfileId,
|
|
462
70
|
availabilityCancellationPolicyProfileId,
|
|
463
71
|
}: NewBookingFlowProps) {
|
|
464
|
-
const { env,
|
|
72
|
+
const { env, analytics, catalog } = useBookingHost();
|
|
465
73
|
const { t } = useTranslations();
|
|
466
74
|
const { locale } = useLocale();
|
|
467
75
|
const companyTimezone = useCompanyTimezone(); // Get timezone from context
|
|
@@ -476,7 +84,6 @@ export function NewBookingFlow({
|
|
|
476
84
|
suppressCalendarDateScroll,
|
|
477
85
|
mode: bookingAppMode,
|
|
478
86
|
} = useBookingApp();
|
|
479
|
-
const availabilitiesCache = useAvailabilitiesCache();
|
|
480
87
|
const isAdmin = permissions.viewerRole === 'admin';
|
|
481
88
|
const minimumPaxToRun = useMemo(() => {
|
|
482
89
|
const productConfigKey = productId || product.productId;
|
|
@@ -484,10 +91,7 @@ export function NewBookingFlow({
|
|
|
484
91
|
const config = catalog.getProductByIdOrSlug(productConfigKey) as { minimumPaxToRun?: number | null } | null;
|
|
485
92
|
return config?.minimumPaxToRun ?? null;
|
|
486
93
|
}, [catalog, product.productId, productId]);
|
|
487
|
-
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
488
94
|
const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
|
|
489
|
-
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
490
|
-
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
491
95
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
|
492
96
|
const [email, setEmail] = useState('');
|
|
493
97
|
const [firstName, setFirstName] = useState('');
|
|
@@ -495,16 +99,6 @@ export function NewBookingFlow({
|
|
|
495
99
|
const [phoneNumber, setPhoneNumber] = useState('');
|
|
496
100
|
const [promoCodeInput, setPromoCodeInput] = useState('');
|
|
497
101
|
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
|
|
498
|
-
const [promoCodeError, setPromoCodeError] = useState('');
|
|
499
|
-
/** When set by a promo, this policy is forced and shown as a non-selectable row. */
|
|
500
|
-
const [forcedCancellationPolicy, setForcedCancellationPolicy] = useState<{
|
|
501
|
-
id: string;
|
|
502
|
-
label: string;
|
|
503
|
-
refundTiers?: Array<{ hoursBefore: number; refundPercent: number }>;
|
|
504
|
-
changeWindowHoursBefore?: number | null;
|
|
505
|
-
} | null>(null);
|
|
506
|
-
const cancellationPolicyRef = useRef<HTMLDivElement>(null);
|
|
507
|
-
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
508
102
|
const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
|
|
509
103
|
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
510
104
|
// Cancellation: change flow seeds from the booking so totals match the quote (server uses booking.cancellationPolicyId).
|
|
@@ -514,118 +108,20 @@ export function NewBookingFlow({
|
|
|
514
108
|
const [addOnSelections, setAddOnSelections] = useState<Array<{ addOnId: string; variantId?: string; quantity?: number }>>(() =>
|
|
515
109
|
normalizeAddOnSelections(initialValues?.addOnSelections ?? [])
|
|
516
110
|
);
|
|
517
|
-
/** Fetched add-ons for the selected product option */
|
|
518
|
-
const [addOns, setAddOns] = useState<AddOn[]>([]);
|
|
519
|
-
|
|
520
|
-
// Auto-apply promo code when parent page passes one (e.g. partner pages).
|
|
521
|
-
// Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
|
|
522
|
-
useEffect(() => {
|
|
523
|
-
if (!autoAppliedPromoCode) return;
|
|
524
|
-
const normalizedPromo = autoAppliedPromoCode.trim().toUpperCase();
|
|
525
|
-
if (!normalizedPromo) return;
|
|
526
|
-
setPromoCodeInput((current) => current || normalizedPromo);
|
|
527
|
-
}, [autoAppliedPromoCode]);
|
|
528
111
|
const [loading, setLoading] = useState(false);
|
|
529
|
-
const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
|
|
530
|
-
/** True when fetching additional availability (e.g. new month in dropdown) - shows spinner in date picker */
|
|
531
|
-
const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
|
|
532
112
|
const [error, setError] = useState('');
|
|
533
|
-
const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
|
|
534
|
-
/** Precomputed prices from ticketbooth-product-prices per option (optionId -> category -> currency -> price). Used for display; rates[].price is for GYG only. */
|
|
535
|
-
const [precomputedPricesByOption, setPrecomputedPricesByOption] = useState<Record<string, PrecomputedPricesByCategory> | null>(null);
|
|
536
|
-
const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
|
|
537
|
-
const fetchingRef = useRef(false); // Prevent concurrent fetches
|
|
538
|
-
const hasLoadedAvailabilitiesRef = useRef(false); // First successful availability paint completed
|
|
539
|
-
const inFlightRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range currently being fetched
|
|
540
|
-
const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
|
|
541
|
-
const pendingRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range to fetch when current fetch completes (user navigated during fetch)
|
|
542
|
-
const selectedDateHydrationInFlightKeyRef = useRef<string | null>(null);
|
|
543
|
-
const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
|
|
544
|
-
const [selectedDate, setSelectedDate] = useState<string>('');
|
|
545
|
-
const selectedDateRangeExtensionKey = useMemo(() => {
|
|
546
|
-
if (!visibleRange || !selectedDate) return '';
|
|
547
|
-
try {
|
|
548
|
-
return isAfter(parseISO(selectedDate), visibleRange.end) ? selectedDate : '';
|
|
549
|
-
} catch {
|
|
550
|
-
return '';
|
|
551
|
-
}
|
|
552
|
-
}, [selectedDate, visibleRange]);
|
|
553
|
-
const [isItinerarySticky, setIsItinerarySticky] = useState(false);
|
|
554
|
-
const isItineraryStickyRef = useRef(false);
|
|
555
|
-
const [isMobile, setIsMobile] = useState(false);
|
|
556
|
-
const [showTooltip, setShowTooltip] = useState(false);
|
|
557
|
-
const itineraryRef = useRef<HTMLDivElement>(null);
|
|
558
|
-
const [showCheckoutModal, setShowCheckoutModal] = useState(false);
|
|
559
|
-
/** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
|
|
560
|
-
const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
|
|
561
|
-
/** True while Stripe is confirming payment/redirecting; skip unload cancellation during this window. */
|
|
562
|
-
const paymentSubmitInFlightRef = useRef(false);
|
|
563
113
|
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
564
114
|
const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
|
|
565
115
|
const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
|
|
566
|
-
const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
|
|
567
|
-
const [checkoutModalData, setCheckoutModalData] = useState<{
|
|
568
|
-
reservationReference: string;
|
|
569
|
-
reservationExpiration?: string;
|
|
570
|
-
customerLastName?: string;
|
|
571
|
-
bookingDate?: string;
|
|
572
|
-
successUrlOverride?: string;
|
|
573
|
-
ticketLines: CheckoutModalLineItem[];
|
|
574
|
-
feeLineItems: OrderSummary['feeLineItems'];
|
|
575
|
-
returnPriceAdjustment: number;
|
|
576
|
-
cancellationPolicyFee: number;
|
|
577
|
-
cancellationPolicyLabel?: string;
|
|
578
|
-
subtotal: number;
|
|
579
|
-
tax: number;
|
|
580
|
-
total: number;
|
|
581
|
-
promoDiscountAmount?: number;
|
|
582
|
-
discountLabel?: string | null;
|
|
583
|
-
totalQuantity: number;
|
|
584
|
-
isTaxIncludedInPrice: boolean;
|
|
585
|
-
taxRate: number;
|
|
586
|
-
changeTotals?: {
|
|
587
|
-
previousTotal: number;
|
|
588
|
-
newTotal: number;
|
|
589
|
-
differenceTotal: number;
|
|
590
|
-
};
|
|
591
|
-
} | null>(null);
|
|
592
116
|
/** Admin only: skip sending confirmation at creation (provider dashboard). */
|
|
593
117
|
const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
|
|
594
118
|
/** Admin only: disable all auto communications for this booking (provider dashboard). */
|
|
595
119
|
const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
|
|
596
|
-
/** Admin only: show choice to pay now or confirm without payment (full balance owed). */
|
|
597
|
-
const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
|
|
598
120
|
const hasAppliedInitialValuesRef = useRef(false);
|
|
599
121
|
const hasAppliedInitialQuantitiesRef = useRef(false);
|
|
600
|
-
const hasHydratedAddOnsFromReceiptRef = useRef(false);
|
|
601
|
-
const hasAutoSelectedPartnerDateRef = useRef(false);
|
|
602
|
-
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
603
122
|
useEffect(() => {
|
|
604
123
|
setPartnerAttributionConfirmed(false);
|
|
605
124
|
}, [flowUi?.partnerAttributionSummary, flowUi?.partnerAttributionConfirmLabel]);
|
|
606
|
-
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
607
|
-
reservationReference: string;
|
|
608
|
-
reservationExpiration?: string;
|
|
609
|
-
checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
|
|
610
|
-
totalAmount: number;
|
|
611
|
-
datePart: string;
|
|
612
|
-
timePart: string;
|
|
613
|
-
availabilityProductOptionId: string;
|
|
614
|
-
itineraryDisplay?: ItineraryDisplayStep[] | null;
|
|
615
|
-
clientSecret: string;
|
|
616
|
-
ticketLinesForModal: CheckoutModalLineItem[];
|
|
617
|
-
feeLineItems: OrderSummary['feeLineItems'];
|
|
618
|
-
returnPriceAdjustment: number;
|
|
619
|
-
cancellationPolicyFee: number;
|
|
620
|
-
cancellationPolicyLabel?: string;
|
|
621
|
-
subtotal: number;
|
|
622
|
-
tax: number;
|
|
623
|
-
totalQuantity: number;
|
|
624
|
-
isTaxIncludedInPrice: boolean;
|
|
625
|
-
taxRate: number;
|
|
626
|
-
promoDiscountAmount: number;
|
|
627
|
-
discountLabel?: string | null;
|
|
628
|
-
} | null>(null);
|
|
629
125
|
// Get all active product options (memoized to prevent recreating array on each render)
|
|
630
126
|
const activeOptions = useMemo(() =>
|
|
631
127
|
product.options?.filter(opt => opt.status === 'ACTIVE') || [],
|
|
@@ -656,895 +152,77 @@ export function NewBookingFlow({
|
|
|
656
152
|
|
|
657
153
|
const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
|
|
658
154
|
const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
isAvailabilityWithinPublicPickupUnknownCutoff(selectedAvailability, bookingCutoffNow),
|
|
665
|
-
[bookingCutoffNow, isAdmin, selectedAvailability],
|
|
666
|
-
);
|
|
667
|
-
|
|
668
|
-
useEffect(() => {
|
|
669
|
-
if (!hidePickupSkipOption || !pickupLocationSkipped) return;
|
|
670
|
-
setPickupLocationSkipped(false);
|
|
671
|
-
}, [hidePickupSkipOption, pickupLocationSkipped]);
|
|
672
|
-
|
|
673
|
-
// Fire view_item when product is first displayed
|
|
674
|
-
const hasFiredViewItem = useRef(false);
|
|
675
|
-
useEffect(() => {
|
|
676
|
-
if (!hasFiredViewItem.current && product) {
|
|
677
|
-
hasFiredViewItem.current = true;
|
|
678
|
-
const id = productId || product.productId;
|
|
679
|
-
const price = product.minPriceByCurrency?.[currency] ?? 0;
|
|
680
|
-
analytics.trackViewItem(id, product.name, price, currency);
|
|
681
|
-
}
|
|
682
|
-
}, [product, productId, currency]);
|
|
683
|
-
|
|
684
|
-
// Helper function to check if we need to fetch a date range
|
|
685
|
-
const needsFetch = (start: Date, end: Date): boolean => {
|
|
686
|
-
if (fetchedRangesRef.current.length === 0) return true;
|
|
687
|
-
|
|
688
|
-
// Check if the requested range is fully covered by fetched ranges
|
|
689
|
-
// For simplicity, check if any single fetched range fully covers the requested range
|
|
690
|
-
return !fetchedRangesRef.current.some(range => {
|
|
691
|
-
const rangeStart = range.start.getTime();
|
|
692
|
-
const rangeEnd = range.end.getTime();
|
|
693
|
-
const reqStart = start.getTime();
|
|
694
|
-
const reqEnd = end.getTime();
|
|
695
|
-
|
|
696
|
-
// Check if this fetched range fully covers the requested range
|
|
697
|
-
return rangeStart <= reqStart && rangeEnd >= reqEnd;
|
|
698
|
-
});
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
/** Re-fetch current calendar window so vacancies/booked counts match server after a reserve race. */
|
|
702
|
-
const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
|
|
703
|
-
if (isPartialLaunch || !visibleRange || activeOptions.length === 0) {
|
|
704
|
-
return [];
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
|
|
708
|
-
const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
|
|
709
|
-
const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
|
|
710
|
-
? startOfEarliestDay
|
|
711
|
-
: visibleRange.start;
|
|
712
|
-
let clampedEnd = isAfter(visibleRange.end, endOfLatestDay) ? endOfLatestDay : visibleRange.end;
|
|
713
|
-
|
|
714
|
-
if (selectedDate) {
|
|
715
|
-
try {
|
|
716
|
-
const selectedDateObj = parseISO(selectedDate);
|
|
717
|
-
if (isAfter(selectedDateObj, clampedEnd)) {
|
|
718
|
-
clampedEnd = selectedDateObj;
|
|
719
|
-
}
|
|
720
|
-
} catch {
|
|
721
|
-
/* ignore */
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
let startDateStr: string;
|
|
726
|
-
let endDateStr: string;
|
|
727
|
-
|
|
728
|
-
if (isPrivateShuttle) {
|
|
729
|
-
startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
|
|
730
|
-
endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
|
|
731
|
-
} else {
|
|
732
|
-
const startDateInTz = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
|
|
733
|
-
const endDateInTz = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
|
|
734
|
-
const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
|
|
735
|
-
const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
|
|
736
|
-
startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
737
|
-
endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
|
|
741
|
-
allOptions: true,
|
|
742
|
-
promoCode: appliedPromoCode || undefined,
|
|
743
|
-
...(pricingProfileIdForAvailabilities
|
|
744
|
-
? { pricingProfileId: pricingProfileIdForAvailabilities }
|
|
745
|
-
: {}),
|
|
746
|
-
...(cancellationPolicyProfileIdForAvailabilities
|
|
747
|
-
? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
|
|
748
|
-
: {}),
|
|
749
|
-
});
|
|
750
|
-
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
751
|
-
setPricingConfig((prev) => {
|
|
752
|
-
if (!prev) {
|
|
753
|
-
pricingConfigSetRef.current = true;
|
|
754
|
-
return result.pricingConfig!;
|
|
755
|
-
}
|
|
756
|
-
return prev;
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
const results = [{
|
|
760
|
-
optionId: product.productId,
|
|
761
|
-
availabilities: result.availabilities.map((avail) => availabilityWithOptionId(avail)),
|
|
762
|
-
precomputedPrices: result.precomputedPrices,
|
|
763
|
-
precomputedPricesByOption: result.precomputedPricesByOption,
|
|
764
|
-
pricingConfig: result.pricingConfig,
|
|
765
|
-
}];
|
|
766
|
-
|
|
767
|
-
const allFetchedAvailabilities = results.flatMap((r) => r.availabilities);
|
|
768
|
-
|
|
769
|
-
setPrecomputedPricesByOption((prev) => {
|
|
770
|
-
const next = { ...(prev || {}) };
|
|
771
|
-
results.forEach((r) => {
|
|
772
|
-
mergePrecomputedPricesFromAvailabilityResult(next, r);
|
|
773
|
-
});
|
|
774
|
-
return Object.keys(next).length > 0 ? next : prev;
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
let mergedOut: Availability[] = [];
|
|
778
|
-
setAvailabilities((prev) => {
|
|
779
|
-
const existingMap = new Map(prev.map((avail) => [getAvailabilityCacheKey(avail), avail]));
|
|
780
|
-
mergeAvailabilitiesIntoMap(existingMap, allFetchedAvailabilities);
|
|
781
|
-
mergedOut = Array.from(existingMap.values());
|
|
782
|
-
return mergedOut;
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
|
|
786
|
-
fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
787
|
-
const mergedRanges: Array<{ start: Date; end: Date }> = [];
|
|
788
|
-
for (const r of fetchedRangesRef.current) {
|
|
789
|
-
if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].end < r.start) {
|
|
790
|
-
mergedRanges.push({ start: r.start, end: r.end });
|
|
791
|
-
} else {
|
|
792
|
-
mergedRanges[mergedRanges.length - 1].end =
|
|
793
|
-
r.end > mergedRanges[mergedRanges.length - 1].end
|
|
794
|
-
? r.end
|
|
795
|
-
: mergedRanges[mergedRanges.length - 1].end;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
fetchedRangesRef.current = mergedRanges;
|
|
799
|
-
|
|
800
|
-
const cacheKey = availabilitiesCache
|
|
801
|
-
? buildAvailabilitiesCacheKey(
|
|
802
|
-
product.productId,
|
|
803
|
-
activeOptionIdsKey,
|
|
804
|
-
appliedPromoCode,
|
|
805
|
-
pricingProfileIdForAvailabilities,
|
|
806
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
807
|
-
)
|
|
808
|
-
: null;
|
|
809
|
-
if (cacheKey && availabilitiesCache) {
|
|
810
|
-
const existingCache = availabilitiesCache.get(cacheKey);
|
|
811
|
-
const existingAvailabilities = existingCache?.availabilities ?? [];
|
|
812
|
-
const mergedAvailabilitiesMap = new Map(
|
|
813
|
-
existingAvailabilities.map((a) => [getAvailabilityCacheKey(a), a])
|
|
814
|
-
);
|
|
815
|
-
mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, allFetchedAvailabilities);
|
|
816
|
-
const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
|
|
817
|
-
results.forEach((r) => {
|
|
818
|
-
mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r);
|
|
819
|
-
});
|
|
820
|
-
const firstPricingConfig =
|
|
821
|
-
(results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ??
|
|
822
|
-
existingCache?.pricingConfig ??
|
|
823
|
-
null;
|
|
824
|
-
availabilitiesCache.merge(cacheKey, {
|
|
825
|
-
fetchedRanges: mergedRanges,
|
|
826
|
-
availabilities: Array.from(mergedAvailabilitiesMap.values()),
|
|
827
|
-
pricingConfig: firstPricingConfig,
|
|
828
|
-
precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
return mergedOut;
|
|
833
|
-
}, [
|
|
834
|
-
isPartialLaunch,
|
|
835
|
-
visibleRange,
|
|
836
|
-
companyTimezone,
|
|
155
|
+
const {
|
|
156
|
+
selectedAvailability,
|
|
157
|
+
setSelectedAvailability,
|
|
158
|
+
selectedReturnOption,
|
|
159
|
+
setSelectedReturnOption,
|
|
837
160
|
selectedDate,
|
|
838
|
-
|
|
161
|
+
setSelectedDate,
|
|
162
|
+
loadingAvailabilities,
|
|
163
|
+
isFetchingMoreAvailabilities,
|
|
164
|
+
pricingConfig,
|
|
165
|
+
precomputedPricesByOption,
|
|
166
|
+
publicVisibleAvailabilities,
|
|
167
|
+
availabilitiesByDate,
|
|
168
|
+
dates,
|
|
169
|
+
handleVisibleRangeChange,
|
|
170
|
+
reloadAvailabilitiesAfterReserveConflict,
|
|
171
|
+
refreshSelectedDateDetailsForCheckout,
|
|
172
|
+
fetchedRangesRef,
|
|
173
|
+
} = useStandardBookingAvailability({
|
|
174
|
+
product,
|
|
839
175
|
activeOptions,
|
|
840
|
-
appliedPromoCode,
|
|
841
|
-
pricingProfileIdForAvailabilities,
|
|
842
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
843
|
-
product.productId,
|
|
844
176
|
activeOptionIdsKey,
|
|
845
|
-
availabilitiesCache,
|
|
846
|
-
]);
|
|
847
|
-
|
|
848
|
-
/** Fresh selected-day check before reserve so stale calendar vacancies are corrected before payment. */
|
|
849
|
-
const refreshSelectedDateDetailsForCheckout = useCallback(async (): Promise<Availability[]> => {
|
|
850
|
-
if (!selectedDate || activeOptions.length === 0) return [];
|
|
851
|
-
|
|
852
|
-
let startDateStr: string;
|
|
853
|
-
let endDateStr: string;
|
|
854
|
-
if (isPrivateShuttle) {
|
|
855
|
-
startDateStr = selectedDate;
|
|
856
|
-
endDateStr = selectedDate;
|
|
857
|
-
} else {
|
|
858
|
-
const startMoment = fromZonedTime(parseISO(`${selectedDate}T00:00:00.000`), companyTimezone);
|
|
859
|
-
const endMoment = fromZonedTime(parseISO(`${selectedDate}T23:59:59.999`), companyTimezone);
|
|
860
|
-
startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
861
|
-
endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
|
|
865
|
-
allOptions: true,
|
|
866
|
-
promoCode: appliedPromoCode || undefined,
|
|
867
|
-
...(pricingProfileIdForAvailabilities
|
|
868
|
-
? { pricingProfileId: pricingProfileIdForAvailabilities }
|
|
869
|
-
: {}),
|
|
870
|
-
...(cancellationPolicyProfileIdForAvailabilities
|
|
871
|
-
? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
|
|
872
|
-
: {}),
|
|
873
|
-
});
|
|
874
|
-
const results = [{
|
|
875
|
-
optionId: product.productId,
|
|
876
|
-
availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
|
|
877
|
-
precomputedPrices: result.precomputedPrices,
|
|
878
|
-
precomputedPricesByOption: result.precomputedPricesByOption,
|
|
879
|
-
pricingConfig: result.pricingConfig,
|
|
880
|
-
}];
|
|
881
|
-
const detailedAvailabilities = results.flatMap((r) => r.availabilities);
|
|
882
|
-
if (detailedAvailabilities.length === 0) return [];
|
|
883
|
-
|
|
884
|
-
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
885
|
-
setPricingConfig(result.pricingConfig);
|
|
886
|
-
pricingConfigSetRef.current = true;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
setPrecomputedPricesByOption((prev) => {
|
|
890
|
-
const next = { ...(prev || {}) };
|
|
891
|
-
results.forEach((r) => mergePrecomputedPricesFromAvailabilityResult(next, r));
|
|
892
|
-
return Object.keys(next).length > 0 ? next : prev;
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
let mergedOut: Availability[] = [];
|
|
896
|
-
setAvailabilities((prev) => {
|
|
897
|
-
const merged = new Map(prev.map((availability) => [getAvailabilityCacheKey(availability), availability]));
|
|
898
|
-
mergeAvailabilitiesIntoMap(merged, detailedAvailabilities);
|
|
899
|
-
mergedOut = Array.from(merged.values());
|
|
900
|
-
return mergedOut;
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
const cacheKey = availabilitiesCache
|
|
904
|
-
? buildAvailabilitiesCacheKey(
|
|
905
|
-
product.productId,
|
|
906
|
-
activeOptionIdsKey,
|
|
907
|
-
appliedPromoCode,
|
|
908
|
-
pricingProfileIdForAvailabilities,
|
|
909
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
910
|
-
)
|
|
911
|
-
: null;
|
|
912
|
-
if (cacheKey && availabilitiesCache) {
|
|
913
|
-
const existingCache = availabilitiesCache.get(cacheKey);
|
|
914
|
-
const mergedAvailabilities = new Map(
|
|
915
|
-
(existingCache?.availabilities ?? []).map((availability) => [
|
|
916
|
-
getAvailabilityCacheKey(availability),
|
|
917
|
-
availability,
|
|
918
|
-
])
|
|
919
|
-
);
|
|
920
|
-
mergeAvailabilitiesIntoMap(mergedAvailabilities, detailedAvailabilities);
|
|
921
|
-
const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
|
|
922
|
-
results.forEach((r) => mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r));
|
|
923
|
-
availabilitiesCache.merge(cacheKey, {
|
|
924
|
-
fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
|
|
925
|
-
availabilities: Array.from(mergedAvailabilities.values()),
|
|
926
|
-
pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
|
|
927
|
-
precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
return detailedAvailabilities;
|
|
932
|
-
}, [
|
|
933
|
-
selectedDate,
|
|
934
|
-
activeOptions.length,
|
|
935
177
|
isPrivateShuttle,
|
|
936
|
-
companyTimezone,
|
|
937
|
-
product.productId,
|
|
938
|
-
appliedPromoCode,
|
|
939
|
-
pricingProfileIdForAvailabilities,
|
|
940
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
941
|
-
availabilitiesCache,
|
|
942
|
-
activeOptionIdsKey,
|
|
943
|
-
]);
|
|
944
|
-
|
|
945
|
-
// Initialize visible range around today during the season, falling back to season open outside it.
|
|
946
|
-
useEffect(() => {
|
|
947
|
-
if (!visibleRange) {
|
|
948
|
-
const initialStart = getInitialAvailabilityRangeStart(companyTimezone);
|
|
949
|
-
const initialEnd = addWeeks(initialStart, INITIAL_FETCH_WEEKS + VISIBLE_RANGE_BUFFER_WEEKS);
|
|
950
|
-
setVisibleRange({ start: initialStart, end: initialEnd });
|
|
951
|
-
}
|
|
952
|
-
}, [companyTimezone, visibleRange]);
|
|
953
|
-
|
|
954
|
-
// Fetch availabilities for visible range + buffer
|
|
955
|
-
useEffect(() => {
|
|
956
|
-
if (isPartialLaunch) {
|
|
957
|
-
setLoadingAvailabilities(false);
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
if (activeOptions.length === 0) {
|
|
961
|
-
setError('No active product options available');
|
|
962
|
-
setLoadingAvailabilities(false);
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
if (!visibleRange) {
|
|
967
|
-
// Wait for initial range to be set
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
async function fetchAvailabilities() {
|
|
972
|
-
// Prevent concurrent fetches - store range to fetch when current one completes
|
|
973
|
-
if (fetchingRef.current && visibleRange) {
|
|
974
|
-
const inFlight = inFlightRangeRef.current;
|
|
975
|
-
if (
|
|
976
|
-
inFlight &&
|
|
977
|
-
inFlight.start.getTime() === visibleRange.start.getTime() &&
|
|
978
|
-
inFlight.end.getTime() === visibleRange.end.getTime()
|
|
979
|
-
) {
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
pendingRangeRef.current = { start: visibleRange.start, end: visibleRange.end };
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Clamp to available date range (use company timezone - date-fns startOfDay/endOfDay use local TZ which can exclude Oct 12)
|
|
987
|
-
// For end date, we need end of Oct 12 in company timezone (inclusive)
|
|
988
|
-
if (!visibleRange) return;
|
|
989
|
-
|
|
990
|
-
const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
|
|
991
|
-
const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
|
|
992
|
-
const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
|
|
993
|
-
? startOfEarliestDay
|
|
994
|
-
: visibleRange.start;
|
|
995
|
-
let clampedEnd = isAfter(visibleRange.end, endOfLatestDay)
|
|
996
|
-
? endOfLatestDay
|
|
997
|
-
: visibleRange.end;
|
|
998
|
-
|
|
999
|
-
// Include an externally seeded selected date only when it falls outside the current calendar window.
|
|
1000
|
-
// Normal date clicks are already inside visibleRange and should not re-fetch the broad calendar summary.
|
|
1001
|
-
if (selectedDateRangeExtensionKey) {
|
|
1002
|
-
try {
|
|
1003
|
-
const selectedDateObj = parseISO(selectedDateRangeExtensionKey);
|
|
1004
|
-
if (isAfter(selectedDateObj, clampedEnd)) {
|
|
1005
|
-
clampedEnd = selectedDateObj;
|
|
1006
|
-
}
|
|
1007
|
-
} catch {
|
|
1008
|
-
// Ignore parse errors
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// Check cache first - avoid refetch when reopening same product
|
|
1013
|
-
const cacheKey = availabilitiesCache
|
|
1014
|
-
? buildAvailabilitiesCacheKey(
|
|
1015
|
-
product.productId,
|
|
1016
|
-
activeOptionIdsKey,
|
|
1017
|
-
appliedPromoCode,
|
|
1018
|
-
pricingProfileIdForAvailabilities,
|
|
1019
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
1020
|
-
)
|
|
1021
|
-
: null;
|
|
1022
|
-
const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
|
|
1023
|
-
let shouldRevalidateCachedRange = false;
|
|
1024
|
-
if (cached && cached.availabilities.length > 0) {
|
|
1025
|
-
const cachedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(cached.availabilities);
|
|
1026
|
-
if (cachedInitialQuantities) {
|
|
1027
|
-
setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : cachedInitialQuantities));
|
|
1028
|
-
}
|
|
1029
|
-
const cacheCoversRange = cached.fetchedRanges.some(
|
|
1030
|
-
(r) => r.start.getTime() <= clampedStart.getTime() && r.end.getTime() >= clampedEnd.getTime()
|
|
1031
|
-
);
|
|
1032
|
-
const isStale = availabilitiesCache?.isStale(cached) ?? false;
|
|
1033
|
-
if (cacheCoversRange) {
|
|
1034
|
-
shouldRevalidateCachedRange = true;
|
|
1035
|
-
setAvailabilities(cached.availabilities);
|
|
1036
|
-
if (cached.availabilities.length > 0) {
|
|
1037
|
-
hasLoadedAvailabilitiesRef.current = true;
|
|
1038
|
-
}
|
|
1039
|
-
if (cached.pricingConfig) {
|
|
1040
|
-
setPricingConfig(cached.pricingConfig);
|
|
1041
|
-
pricingConfigSetRef.current = true;
|
|
1042
|
-
}
|
|
1043
|
-
if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
|
|
1044
|
-
setPrecomputedPricesByOption(cached.precomputedPricesByOption);
|
|
1045
|
-
}
|
|
1046
|
-
setLoadingAvailabilities(false);
|
|
1047
|
-
setIsFetchingMoreAvailabilities(false);
|
|
1048
|
-
if (!isStale) {
|
|
1049
|
-
fetchedRangesRef.current = [...cached.fetchedRanges];
|
|
1050
|
-
// Show cached availability instantly, then quietly revalidate the visible range.
|
|
1051
|
-
}
|
|
1052
|
-
// Stale-while-revalidate: show cached data, fetch in background.
|
|
1053
|
-
}
|
|
1054
|
-
// Partial cache: show cached data immediately, then fetch missing range below
|
|
1055
|
-
setAvailabilities(cached.availabilities);
|
|
1056
|
-
if (cached.availabilities.length > 0) {
|
|
1057
|
-
hasLoadedAvailabilitiesRef.current = true;
|
|
1058
|
-
}
|
|
1059
|
-
if (cached.pricingConfig) {
|
|
1060
|
-
setPricingConfig(cached.pricingConfig);
|
|
1061
|
-
pricingConfigSetRef.current = true;
|
|
1062
|
-
}
|
|
1063
|
-
if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
|
|
1064
|
-
setPrecomputedPricesByOption(cached.precomputedPricesByOption);
|
|
1065
|
-
}
|
|
1066
|
-
fetchedRangesRef.current = [...cached.fetchedRanges];
|
|
1067
|
-
setLoadingAvailabilities(false);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
// Check if we need to fetch this range
|
|
1071
|
-
// Backend always returns CAD prices without fee/tax, so no need to refetch on currency change
|
|
1072
|
-
const shouldFetch = shouldRevalidateCachedRange || needsFetch(clampedStart, clampedEnd);
|
|
1073
|
-
if (!shouldFetch) {
|
|
1074
|
-
// Range already fetched - ensure loading state is cleared
|
|
1075
|
-
setLoadingAvailabilities(false);
|
|
1076
|
-
setIsFetchingMoreAvailabilities(false);
|
|
1077
|
-
fetchingRef.current = false;
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const hasPartialCache = cached && cached.availabilities.length > 0;
|
|
1082
|
-
const shouldUsePrimaryLoader =
|
|
1083
|
-
!hasPartialCache && !hasLoadedAvailabilitiesRef.current;
|
|
1084
|
-
fetchingRef.current = true;
|
|
1085
|
-
inFlightRangeRef.current = {
|
|
1086
|
-
start: new Date(clampedStart),
|
|
1087
|
-
end: new Date(clampedEnd),
|
|
1088
|
-
};
|
|
1089
|
-
if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
|
|
1090
|
-
else if (!shouldRevalidateCachedRange) setIsFetchingMoreAvailabilities(true);
|
|
1091
|
-
|
|
1092
|
-
try {
|
|
1093
|
-
let startDateStr: string;
|
|
1094
|
-
let endDateStr: string;
|
|
1095
|
-
|
|
1096
|
-
if (isPrivateShuttle) {
|
|
1097
|
-
// Private Shuttle: use date-only format (YYYY-MM-DD)
|
|
1098
|
-
startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
|
|
1099
|
-
// Use endOfDay to include the full last day
|
|
1100
|
-
endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
|
|
1101
|
-
} else {
|
|
1102
|
-
// Standard products: API expects UTC. Get full first/last days in company TZ, then format as UTC.
|
|
1103
|
-
const startDateInTz = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
|
|
1104
|
-
const endDateInTz = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
|
|
1105
|
-
const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
|
|
1106
|
-
const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
|
|
1107
|
-
startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
1108
|
-
endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
|
|
1112
|
-
allOptions: true,
|
|
1113
|
-
summary: true,
|
|
1114
|
-
promoCode: appliedPromoCode || undefined,
|
|
1115
|
-
...(pricingProfileIdForAvailabilities
|
|
1116
|
-
? { pricingProfileId: pricingProfileIdForAvailabilities }
|
|
1117
|
-
: {}),
|
|
1118
|
-
...(cancellationPolicyProfileIdForAvailabilities
|
|
1119
|
-
? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
|
|
1120
|
-
: {}),
|
|
1121
|
-
});
|
|
1122
|
-
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
1123
|
-
setPricingConfig(prev => {
|
|
1124
|
-
if (!prev) {
|
|
1125
|
-
pricingConfigSetRef.current = true;
|
|
1126
|
-
return result.pricingConfig!;
|
|
1127
|
-
}
|
|
1128
|
-
return prev;
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
|
-
const results = [{
|
|
1132
|
-
optionId: product.productId,
|
|
1133
|
-
availabilities: result.availabilities.map((avail) => availabilityWithOptionId(avail)),
|
|
1134
|
-
precomputedPrices: result.precomputedPrices,
|
|
1135
|
-
precomputedPricesByOption: result.precomputedPricesByOption,
|
|
1136
|
-
pricingConfig: result.pricingConfig,
|
|
1137
|
-
}];
|
|
1138
|
-
const allFetchedAvailabilities = results.flatMap(r => r.availabilities);
|
|
1139
|
-
if (allFetchedAvailabilities.length > 0) {
|
|
1140
|
-
hasLoadedAvailabilitiesRef.current = true;
|
|
1141
|
-
}
|
|
1142
|
-
setPrecomputedPricesByOption(prev => {
|
|
1143
|
-
const next = { ...(prev || {}) };
|
|
1144
|
-
results.forEach(r => {
|
|
1145
|
-
mergePrecomputedPricesFromAvailabilityResult(next, r);
|
|
1146
|
-
});
|
|
1147
|
-
return Object.keys(next).length > 0 ? next : prev;
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
// Merge with existing availabilities (avoid duplicates by dateTime + productOptionId)
|
|
1151
|
-
setAvailabilities(prev => {
|
|
1152
|
-
const existingMap = new Map(
|
|
1153
|
-
prev.map(avail => [getAvailabilityCacheKey(avail), avail])
|
|
1154
|
-
);
|
|
1155
|
-
|
|
1156
|
-
// Merge new availabilities - update existing ones or add new ones
|
|
1157
|
-
mergeAvailabilitiesIntoMap(existingMap, allFetchedAvailabilities);
|
|
1158
|
-
return Array.from(existingMap.values());
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
// Mark this range as fetched
|
|
1162
|
-
fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
|
|
1163
|
-
// Sort and merge overlapping ranges
|
|
1164
|
-
fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
1165
|
-
const merged: Array<{ start: Date; end: Date }> = [];
|
|
1166
|
-
for (const r of fetchedRangesRef.current) {
|
|
1167
|
-
if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
|
|
1168
|
-
merged.push({ start: r.start, end: r.end });
|
|
1169
|
-
} else {
|
|
1170
|
-
merged[merged.length - 1].end = r.end > merged[merged.length - 1].end
|
|
1171
|
-
? r.end
|
|
1172
|
-
: merged[merged.length - 1].end;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
fetchedRangesRef.current = merged;
|
|
1176
|
-
|
|
1177
|
-
// Update cache for instant load when reopening same product
|
|
1178
|
-
if (cacheKey && availabilitiesCache) {
|
|
1179
|
-
const existingCache = availabilitiesCache.get(cacheKey);
|
|
1180
|
-
const existingAvailabilities = existingCache?.availabilities ?? [];
|
|
1181
|
-
const mergedAvailabilitiesMap = new Map(
|
|
1182
|
-
existingAvailabilities.map((a) => [getAvailabilityCacheKey(a), a])
|
|
1183
|
-
);
|
|
1184
|
-
mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, allFetchedAvailabilities);
|
|
1185
|
-
const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
|
|
1186
|
-
results.forEach((r) => {
|
|
1187
|
-
mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r);
|
|
1188
|
-
});
|
|
1189
|
-
const firstPricingConfig = (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ?? existingCache?.pricingConfig ?? null;
|
|
1190
|
-
availabilitiesCache.merge(cacheKey, {
|
|
1191
|
-
fetchedRanges: merged,
|
|
1192
|
-
availabilities: Array.from(mergedAvailabilitiesMap.values()),
|
|
1193
|
-
pricingConfig: firstPricingConfig,
|
|
1194
|
-
precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
|
|
1195
|
-
});
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// Initialize quantities based on first availability's categories (only if not already set)
|
|
1199
|
-
// Use functional update to avoid dependency on quantities state
|
|
1200
|
-
const fetchedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(allFetchedAvailabilities);
|
|
1201
|
-
if (fetchedInitialQuantities) {
|
|
1202
|
-
setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : fetchedInitialQuantities));
|
|
1203
|
-
}
|
|
1204
|
-
} catch (err) {
|
|
1205
|
-
if (shouldRevalidateCachedRange && cached?.availabilities.length) {
|
|
1206
|
-
console.warn('Background availability refresh failed; keeping cached availability visible.', err);
|
|
1207
|
-
} else {
|
|
1208
|
-
setError(err instanceof Error ? err.message : 'Failed to load availabilities');
|
|
1209
|
-
console.error('Error fetching availabilities:', err);
|
|
1210
|
-
}
|
|
1211
|
-
} finally {
|
|
1212
|
-
setLoadingAvailabilities(false);
|
|
1213
|
-
setIsFetchingMoreAvailabilities(false);
|
|
1214
|
-
fetchingRef.current = false;
|
|
1215
|
-
inFlightRangeRef.current = null;
|
|
1216
|
-
// If user navigated during fetch, trigger fetch for the pending range
|
|
1217
|
-
const pending = pendingRangeRef.current;
|
|
1218
|
-
if (pending) {
|
|
1219
|
-
pendingRangeRef.current = null;
|
|
1220
|
-
setVisibleRange({ start: pending.start, end: pending.end });
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
fetchAvailabilities();
|
|
1226
|
-
// Use activeOptionIdsKey only — activeOptions is a new array reference when product.options identity changes and would refetch in a tight loop.
|
|
1227
|
-
}, [
|
|
1228
178
|
isPartialLaunch,
|
|
1229
|
-
|
|
1230
|
-
activeOptionIdsKey,
|
|
1231
|
-
isPrivateShuttle,
|
|
179
|
+
isAdmin,
|
|
1232
180
|
companyTimezone,
|
|
1233
|
-
selectedDateRangeExtensionKey,
|
|
1234
|
-
appliedPromoCode,
|
|
1235
|
-
pricingProfileIdForAvailabilities,
|
|
1236
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
1237
|
-
]);
|
|
1238
|
-
|
|
1239
|
-
// When promo or partner pricing profile changes, clear fetched ranges so we refetch with new pricing
|
|
1240
|
-
useEffect(() => {
|
|
1241
|
-
fetchedRangesRef.current = [];
|
|
1242
|
-
}, [
|
|
1243
181
|
appliedPromoCode,
|
|
1244
182
|
pricingProfileIdForAvailabilities,
|
|
1245
183
|
cancellationPolicyProfileIdForAvailabilities,
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
lastVisibleRangeRef.current = { start, end };
|
|
1260
|
-
// Always update state to trigger fetch, even if needsFetch might return false
|
|
1261
|
-
// The needsFetch check will prevent unnecessary API calls, but we want the state update
|
|
1262
|
-
setVisibleRange({ start, end });
|
|
1263
|
-
}
|
|
1264
|
-
}, []);
|
|
1265
|
-
|
|
1266
|
-
const publicVisibleAvailabilities = useMemo(
|
|
1267
|
-
() =>
|
|
1268
|
-
isAdmin
|
|
1269
|
-
? availabilities
|
|
1270
|
-
: filterAvailabilitiesAfterPublicBookingCutoff(availabilities, bookingCutoffNow, bookingCutoffMinutes),
|
|
1271
|
-
[availabilities, bookingCutoffMinutes, bookingCutoffNow, isAdmin],
|
|
1272
|
-
);
|
|
1273
|
-
|
|
1274
|
-
// Group availabilities by date (in company timezone) and sort by time
|
|
1275
|
-
// Memoized to prevent recalculation on every render
|
|
1276
|
-
const availabilitiesByDate = useMemo(() => {
|
|
1277
|
-
const grouped = publicVisibleAvailabilities.reduce((acc, avail) => {
|
|
1278
|
-
// Parse the dateTime and extract the date in company timezone
|
|
1279
|
-
const dateTime = parseAvailabilityDateTime(avail.dateTime);
|
|
1280
|
-
const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
|
|
1281
|
-
if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
|
|
1282
|
-
acc[dateInCompanyTz].push(avail);
|
|
1283
|
-
return acc;
|
|
1284
|
-
}, {} as Record<string, Availability[]>);
|
|
1285
|
-
|
|
1286
|
-
// Sort availabilities within each date by time (create new sorted arrays, don't mutate)
|
|
1287
|
-
const sorted: Record<string, Availability[]> = {};
|
|
1288
|
-
Object.keys(grouped).forEach(date => {
|
|
1289
|
-
sorted[date] = [...grouped[date]].sort((a, b) => {
|
|
1290
|
-
const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
|
|
1291
|
-
const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
|
|
1292
|
-
return timeA - timeB;
|
|
1293
|
-
});
|
|
1294
|
-
});
|
|
1295
|
-
|
|
1296
|
-
return sorted;
|
|
1297
|
-
}, [publicVisibleAvailabilities, companyTimezone]);
|
|
1298
|
-
|
|
1299
|
-
const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
|
|
1300
|
-
|
|
1301
|
-
useEffect(() => {
|
|
1302
|
-
if (!selectedDate || activeOptions.length === 0) return;
|
|
1303
|
-
if (!selectedAvailability) return;
|
|
1304
|
-
if (!availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)) return;
|
|
1305
|
-
if (selectedAvailability.isSummary !== true) return;
|
|
1306
|
-
const hydrationKey = [
|
|
1307
|
-
product.productId,
|
|
1308
|
-
selectedDate,
|
|
1309
|
-
activeOptionIdsKey,
|
|
1310
|
-
appliedPromoCode ?? '',
|
|
1311
|
-
pricingProfileIdForAvailabilities ?? '',
|
|
1312
|
-
cancellationPolicyProfileIdForAvailabilities ?? '',
|
|
1313
|
-
].join('|');
|
|
1314
|
-
if (selectedDateHydrationInFlightKeyRef.current === hydrationKey) return;
|
|
1315
|
-
selectedDateHydrationInFlightKeyRef.current = hydrationKey;
|
|
1316
|
-
|
|
1317
|
-
let cancelled = false;
|
|
1318
|
-
async function hydrateSelectedDateDetails() {
|
|
1319
|
-
setIsFetchingMoreAvailabilities(true);
|
|
1320
|
-
try {
|
|
1321
|
-
let startDateStr: string;
|
|
1322
|
-
let endDateStr: string;
|
|
1323
|
-
|
|
1324
|
-
if (isPrivateShuttle) {
|
|
1325
|
-
startDateStr = selectedDate;
|
|
1326
|
-
endDateStr = selectedDate;
|
|
1327
|
-
} else {
|
|
1328
|
-
const startMoment = fromZonedTime(parseISO(`${selectedDate}T00:00:00.000`), companyTimezone);
|
|
1329
|
-
const endMoment = fromZonedTime(parseISO(`${selectedDate}T23:59:59.999`), companyTimezone);
|
|
1330
|
-
startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
1331
|
-
endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
|
|
1335
|
-
allOptions: true,
|
|
1336
|
-
responseMode: 'booking-day',
|
|
1337
|
-
promoCode: appliedPromoCode || undefined,
|
|
1338
|
-
...(pricingProfileIdForAvailabilities
|
|
1339
|
-
? { pricingProfileId: pricingProfileIdForAvailabilities }
|
|
1340
|
-
: {}),
|
|
1341
|
-
...(cancellationPolicyProfileIdForAvailabilities
|
|
1342
|
-
? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
|
|
1343
|
-
: {}),
|
|
1344
|
-
});
|
|
1345
|
-
const results = [{
|
|
1346
|
-
optionId: product.productId,
|
|
1347
|
-
availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
|
|
1348
|
-
precomputedPrices: result.precomputedPrices,
|
|
1349
|
-
precomputedPricesByOption: result.precomputedPricesByOption,
|
|
1350
|
-
pricingConfig: result.pricingConfig,
|
|
1351
|
-
}];
|
|
1352
|
-
if (cancelled) return;
|
|
1353
|
-
|
|
1354
|
-
const detailedAvailabilities = results.flatMap((result) => result.availabilities);
|
|
1355
|
-
if (detailedAvailabilities.length === 0) return;
|
|
1356
|
-
|
|
1357
|
-
const firstPricingConfig = results.find((result) => result.pricingConfig)?.pricingConfig;
|
|
1358
|
-
if (firstPricingConfig && !pricingConfigSetRef.current) {
|
|
1359
|
-
setPricingConfig(firstPricingConfig);
|
|
1360
|
-
pricingConfigSetRef.current = true;
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
setPrecomputedPricesByOption((prev) => {
|
|
1364
|
-
const next = { ...(prev || {}) };
|
|
1365
|
-
results.forEach((result) => {
|
|
1366
|
-
mergePrecomputedPricesFromAvailabilityResult(next, result);
|
|
1367
|
-
});
|
|
1368
|
-
return Object.keys(next).length > 0 ? next : prev;
|
|
1369
|
-
});
|
|
1370
|
-
|
|
1371
|
-
setAvailabilities((prev) => {
|
|
1372
|
-
const merged = new Map(prev.map((availability) => [getAvailabilityCacheKey(availability), availability]));
|
|
1373
|
-
mergeAvailabilitiesIntoMap(merged, detailedAvailabilities);
|
|
1374
|
-
return Array.from(merged.values());
|
|
1375
|
-
});
|
|
1376
|
-
const updatedSelection = findMergedAvailabilityForSelection(detailedAvailabilities, selectedAvailability);
|
|
1377
|
-
if (updatedSelection && selectedAvailability && shouldSyncSelectedAvailability(selectedAvailability, updatedSelection)) {
|
|
1378
|
-
setSelectedAvailability(updatedSelection);
|
|
1379
|
-
if (selectedReturnOption && updatedSelection.returnOptions) {
|
|
1380
|
-
const updatedReturnOption = updatedSelection.returnOptions.find(
|
|
1381
|
-
(option) => option.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
1382
|
-
);
|
|
1383
|
-
if (updatedReturnOption) setSelectedReturnOption(updatedReturnOption);
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
const cacheKey = availabilitiesCache
|
|
1388
|
-
? buildAvailabilitiesCacheKey(
|
|
1389
|
-
product.productId,
|
|
1390
|
-
activeOptionIdsKey,
|
|
1391
|
-
appliedPromoCode,
|
|
1392
|
-
pricingProfileIdForAvailabilities,
|
|
1393
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
1394
|
-
)
|
|
1395
|
-
: null;
|
|
1396
|
-
if (cacheKey && availabilitiesCache) {
|
|
1397
|
-
const existingCache = availabilitiesCache.get(cacheKey);
|
|
1398
|
-
const mergedAvailabilities = new Map(
|
|
1399
|
-
(existingCache?.availabilities ?? []).map((availability) => [
|
|
1400
|
-
getAvailabilityCacheKey(availability),
|
|
1401
|
-
availability,
|
|
1402
|
-
])
|
|
1403
|
-
);
|
|
1404
|
-
mergeAvailabilitiesIntoMap(mergedAvailabilities, detailedAvailabilities);
|
|
1405
|
-
const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
|
|
1406
|
-
results.forEach((result) => {
|
|
1407
|
-
mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, result);
|
|
1408
|
-
});
|
|
1409
|
-
availabilitiesCache.merge(cacheKey, {
|
|
1410
|
-
fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
|
|
1411
|
-
availabilities: Array.from(mergedAvailabilities.values()),
|
|
1412
|
-
pricingConfig: firstPricingConfig ?? existingCache?.pricingConfig ?? null,
|
|
1413
|
-
precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
|
|
1414
|
-
});
|
|
1415
|
-
}
|
|
1416
|
-
} catch (err) {
|
|
1417
|
-
if (!cancelled) {
|
|
1418
|
-
console.error('Error hydrating availability details:', err);
|
|
1419
|
-
}
|
|
1420
|
-
} finally {
|
|
1421
|
-
if (selectedDateHydrationInFlightKeyRef.current === hydrationKey) {
|
|
1422
|
-
selectedDateHydrationInFlightKeyRef.current = null;
|
|
1423
|
-
}
|
|
1424
|
-
if (!cancelled) setIsFetchingMoreAvailabilities(false);
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
hydrateSelectedDateDetails();
|
|
1429
|
-
return () => {
|
|
1430
|
-
cancelled = true;
|
|
1431
|
-
};
|
|
1432
|
-
}, [
|
|
184
|
+
bookingCutoffNow,
|
|
185
|
+
bookingCutoffMinutes,
|
|
186
|
+
setQuantities,
|
|
187
|
+
setError,
|
|
188
|
+
});
|
|
189
|
+
const {
|
|
190
|
+
isItinerarySticky,
|
|
191
|
+
isMobile,
|
|
192
|
+
showTooltip,
|
|
193
|
+
setShowTooltip,
|
|
194
|
+
toggleTooltip,
|
|
195
|
+
itineraryRef,
|
|
196
|
+
} = useChangeBookingItineraryUiState({
|
|
1433
197
|
selectedDate,
|
|
1434
198
|
selectedAvailability,
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
isPrivateShuttle,
|
|
1439
|
-
companyTimezone,
|
|
1440
|
-
appliedPromoCode,
|
|
1441
|
-
pricingProfileIdForAvailabilities,
|
|
1442
|
-
cancellationPolicyProfileIdForAvailabilities,
|
|
1443
|
-
availabilitiesCache,
|
|
1444
|
-
product.productId,
|
|
1445
|
-
]);
|
|
199
|
+
useWindowScroll,
|
|
200
|
+
stickyTopPx: flowUi?.itineraryStickyTopOffsetPx,
|
|
201
|
+
});
|
|
1446
202
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
const checkMobile = () => {
|
|
1450
|
-
setIsMobile(window.innerWidth < 640); // sm breakpoint
|
|
1451
|
-
};
|
|
1452
|
-
checkMobile();
|
|
1453
|
-
window.addEventListener('resize', checkMobile);
|
|
1454
|
-
return () => window.removeEventListener('resize', checkMobile);
|
|
203
|
+
const clearAddOnSelections = useCallback(() => {
|
|
204
|
+
setAddOnSelections([]);
|
|
1455
205
|
}, []);
|
|
206
|
+
const { addOns } = useBookingAvailabilityAddOns({
|
|
207
|
+
selectedAvailability,
|
|
208
|
+
companyId: product.companyId,
|
|
209
|
+
onProductOptionChange: clearAddOnSelections,
|
|
210
|
+
});
|
|
1456
211
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
setShowTooltip(false);
|
|
1465
|
-
}
|
|
1466
|
-
};
|
|
1467
|
-
|
|
1468
|
-
document.addEventListener('mousedown', handleClickOutside);
|
|
1469
|
-
document.addEventListener('touchstart', handleClickOutside);
|
|
1470
|
-
|
|
1471
|
-
return () => {
|
|
1472
|
-
document.removeEventListener('mousedown', handleClickOutside);
|
|
1473
|
-
document.removeEventListener('touchstart', handleClickOutside);
|
|
1474
|
-
};
|
|
1475
|
-
}, [showTooltip]);
|
|
212
|
+
const hidePickupSkipOption = useMemo(
|
|
213
|
+
() =>
|
|
214
|
+
!isAdmin &&
|
|
215
|
+
selectedAvailability != null &&
|
|
216
|
+
isAvailabilityWithinPublicPickupUnknownCutoff(selectedAvailability, bookingCutoffNow),
|
|
217
|
+
[bookingCutoffNow, isAdmin, selectedAvailability],
|
|
218
|
+
);
|
|
1476
219
|
|
|
1477
|
-
// Detect when itinerary box becomes sticky
|
|
1478
|
-
// In viavia the scroll usually happens inside the dialog content div, not window.
|
|
1479
|
-
// On full-page partner layouts, we instead listen to window scroll (useWindowScroll = true).
|
|
1480
|
-
const lastStickyChangeRef = useRef<number>(0);
|
|
1481
220
|
useEffect(() => {
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
const findScrollParent = (node: HTMLElement): HTMLElement | null => {
|
|
1486
|
-
let parent = node.parentElement;
|
|
1487
|
-
while (parent) {
|
|
1488
|
-
const { overflowY } = getComputedStyle(parent);
|
|
1489
|
-
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return parent;
|
|
1490
|
-
parent = parent.parentElement;
|
|
1491
|
-
}
|
|
1492
|
-
return null;
|
|
1493
|
-
};
|
|
1494
|
-
|
|
1495
|
-
const scrollParent = findScrollParent(el);
|
|
1496
|
-
const scrollTarget =
|
|
1497
|
-
useWindowScroll || !scrollParent
|
|
1498
|
-
? (typeof window !== 'undefined' ? window : null)
|
|
1499
|
-
: scrollParent;
|
|
1500
|
-
|
|
1501
|
-
let ticking = false;
|
|
1502
|
-
const COOLDOWN_MS = 600; // After a state change, ignore reverse changes for this long (covers 0.25s collapse animation + layout settle)
|
|
1503
|
-
const atTopBand = 48; // px - must scroll back up past this band to expand again (wider = less oscillation at edges)
|
|
1504
|
-
|
|
1505
|
-
const updateStickyState = () => {
|
|
1506
|
-
if (!itineraryRef.current) return;
|
|
1507
|
-
|
|
1508
|
-
const rect = itineraryRef.current.getBoundingClientRect();
|
|
1509
|
-
const currentTop = rect.top;
|
|
1510
|
-
const wasSticky = isItineraryStickyRef.current;
|
|
1511
|
-
|
|
1512
|
-
const containerTop =
|
|
1513
|
-
scrollParent && !useWindowScroll ? scrollParent.getBoundingClientRect().top : 0;
|
|
1514
|
-
const topInset = Math.max(0, flowUi?.itineraryStickyTopOffsetPx ?? 0);
|
|
1515
|
-
const stickLine = containerTop + topInset;
|
|
1516
|
-
const enterStickyThreshold = stickLine + 1;
|
|
1517
|
-
const nextSticky = wasSticky
|
|
1518
|
-
? currentTop >= stickLine - atTopBand && currentTop <= stickLine + atTopBand
|
|
1519
|
-
: currentTop <= enterStickyThreshold;
|
|
1520
|
-
|
|
1521
|
-
if (nextSticky !== wasSticky) {
|
|
1522
|
-
const now = Date.now();
|
|
1523
|
-
if (now - lastStickyChangeRef.current < COOLDOWN_MS) return; // Cooldown: prevent rapid toggling
|
|
1524
|
-
|
|
1525
|
-
lastStickyChangeRef.current = now;
|
|
1526
|
-
isItineraryStickyRef.current = nextSticky;
|
|
1527
|
-
setIsItinerarySticky(nextSticky);
|
|
1528
|
-
}
|
|
1529
|
-
};
|
|
1530
|
-
|
|
1531
|
-
const handleScroll = () => {
|
|
1532
|
-
if (!ticking) {
|
|
1533
|
-
window.requestAnimationFrame(() => {
|
|
1534
|
-
updateStickyState();
|
|
1535
|
-
ticking = false;
|
|
1536
|
-
});
|
|
1537
|
-
ticking = true;
|
|
1538
|
-
}
|
|
1539
|
-
};
|
|
221
|
+
if (!hidePickupSkipOption || !pickupLocationSkipped) return;
|
|
222
|
+
setPickupLocationSkipped(false);
|
|
223
|
+
}, [hidePickupSkipOption, pickupLocationSkipped]);
|
|
1540
224
|
|
|
1541
|
-
|
|
1542
|
-
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
|
|
1543
|
-
updateStickyState();
|
|
1544
|
-
return () => scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1545
|
-
}
|
|
1546
|
-
return undefined;
|
|
1547
|
-
}, [selectedDate, selectedAvailability, useWindowScroll, flowUi?.itineraryStickyTopOffsetPx]); // Re-check when itinerary / scroll mode / host chrome changes
|
|
225
|
+
useBookingViewItemAnalytics({ analytics, product, productId, currency });
|
|
1548
226
|
|
|
1549
227
|
// Find the earliest availability date - memoize with a stable reference
|
|
1550
228
|
// Only recalculate if we don't have a cached value or if the new earliest is actually earlier
|
|
@@ -1572,7 +250,7 @@ export function NewBookingFlow({
|
|
|
1572
250
|
}
|
|
1573
251
|
|
|
1574
252
|
return earliestAvailabilityDateRef.current;
|
|
1575
|
-
}, [dates, availabilitiesByDate]);
|
|
253
|
+
}, [dates, availabilitiesByDate, companyTimezone]);
|
|
1576
254
|
|
|
1577
255
|
const timesForSelectedDate = useMemo(() => {
|
|
1578
256
|
return selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
|
|
@@ -1600,7 +278,6 @@ export function NewBookingFlow({
|
|
|
1600
278
|
const trimmedEmail = initialValues.customer?.email?.trim();
|
|
1601
279
|
const trimmedFirstName = initialValues.customer?.firstName?.trim();
|
|
1602
280
|
const trimmedLastName = initialValues.customer?.lastName?.trim();
|
|
1603
|
-
const trimmedPromo = initialValues.promoCode?.trim().toUpperCase();
|
|
1604
281
|
if (trimmedEmail) setEmail(trimmedEmail);
|
|
1605
282
|
if (trimmedFirstName) setFirstName(trimmedFirstName);
|
|
1606
283
|
if (trimmedLastName) setLastName(trimmedLastName);
|
|
@@ -1608,10 +285,6 @@ export function NewBookingFlow({
|
|
|
1608
285
|
setPickupLocationId(initialValues.pickupLocationId.trim());
|
|
1609
286
|
setPickupLocationSkipped(false);
|
|
1610
287
|
}
|
|
1611
|
-
if (trimmedPromo) {
|
|
1612
|
-
setPromoCodeInput(trimmedPromo);
|
|
1613
|
-
setAppliedPromoCode(trimmedPromo);
|
|
1614
|
-
}
|
|
1615
288
|
hasAppliedInitialValuesRef.current = true;
|
|
1616
289
|
}, [initialValues]);
|
|
1617
290
|
|
|
@@ -1623,7 +296,7 @@ export function NewBookingFlow({
|
|
|
1623
296
|
if (!selectedDate) {
|
|
1624
297
|
setSelectedDate(targetDate);
|
|
1625
298
|
}
|
|
1626
|
-
}, [initialValues?.dateTime, selectedAvailability, selectedDate, companyTimezone]);
|
|
299
|
+
}, [initialValues?.dateTime, selectedAvailability, selectedDate, companyTimezone, setSelectedDate]);
|
|
1627
300
|
|
|
1628
301
|
useEffect(() => {
|
|
1629
302
|
if (
|
|
@@ -1665,842 +338,163 @@ export function NewBookingFlow({
|
|
|
1665
338
|
[applyChangeFlowAddOnFloor]
|
|
1666
339
|
);
|
|
1667
340
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
// Calculate maximum time offset from all pickup locations (for range display when location is unknown)
|
|
1675
|
-
const maxTimeOffsetMinutes = useMemo(() => {
|
|
1676
|
-
if (!product.pickupLocations || product.pickupLocations.length === 0) {
|
|
1677
|
-
return 0;
|
|
1678
|
-
}
|
|
1679
|
-
return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
|
|
1680
|
-
}, [product.pickupLocations]);
|
|
1681
|
-
// Calculate pickup times based on availability times + pickup location offset
|
|
1682
|
-
interface PickupTimeInfo extends Availability {
|
|
1683
|
-
pickupTime: string;
|
|
1684
|
-
displayTime: string;
|
|
1685
|
-
originalTime: string;
|
|
1686
|
-
displayTimeRange?: string; // Time range when pickup location is unknown
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
const pickupTimes = useMemo((): PickupTimeInfo[] => {
|
|
1690
|
-
if (!selectedDate || !timesForSelectedDate.length) {
|
|
1691
|
-
return [];
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
// Show base availability times (without pickup location offset) when pickup location not selected yet
|
|
1695
|
-
// Once pickup location is selected, we can show adjusted times
|
|
1696
|
-
const offsetMinutes = pickupLocationSkipped
|
|
1697
|
-
? 0
|
|
1698
|
-
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
1699
|
-
|
|
1700
|
-
return timesForSelectedDate
|
|
1701
|
-
.filter((avail) => {
|
|
1702
|
-
if (isAdmin) return true;
|
|
1703
|
-
const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
|
|
1704
|
-
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
1705
|
-
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
1706
|
-
: availabilityTime;
|
|
1707
|
-
return isDateAfterPublicBookingCutoff(pickupTime, bookingCutoffNow, bookingCutoffMinutes);
|
|
1708
|
-
})
|
|
1709
|
-
.map(avail => {
|
|
1710
|
-
// Parse the dateTime (which should already be in company timezone from backend)
|
|
1711
|
-
const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
|
|
1712
|
-
const adjustedVacancies = Math.max(0, avail.vacancies ?? 0);
|
|
1713
|
-
|
|
1714
|
-
// Only apply offset if it's set and > 0 and location is selected
|
|
1715
|
-
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
1716
|
-
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
1717
|
-
: availabilityTime;
|
|
1718
|
-
|
|
1719
|
-
// Format in company timezone (not user's local timezone)
|
|
1720
|
-
const displayTime = formatInTimeZone(pickupTime, companyTimezone, 'h:mm a');
|
|
1721
|
-
const originalTime = formatInTimeZone(availabilityTime, companyTimezone, 'h:mm a');
|
|
1722
|
-
|
|
1723
|
-
// If pickup location is skipped, calculate and display time range
|
|
1724
|
-
let displayTimeRange: string | undefined;
|
|
1725
|
-
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
1726
|
-
const latestPickupTime = new Date(availabilityTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
|
|
1727
|
-
const latestTimeStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
1728
|
-
displayTimeRange = `${originalTime} - ${latestTimeStr}`;
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
return {
|
|
1732
|
-
...avail,
|
|
1733
|
-
vacancies: adjustedVacancies,
|
|
1734
|
-
pickupTime: pickupTime.toISOString(),
|
|
1735
|
-
displayTime,
|
|
1736
|
-
originalTime,
|
|
1737
|
-
displayTimeRange,
|
|
1738
|
-
};
|
|
1739
|
-
});
|
|
1740
|
-
}, [
|
|
1741
|
-
bookingCutoffNow,
|
|
1742
|
-
bookingCutoffMinutes,
|
|
1743
|
-
isAdmin,
|
|
1744
|
-
selectedDate,
|
|
341
|
+
const handleSelectedAvailabilityExpired = useCallback(() => {
|
|
342
|
+
setSelectedAvailability(null);
|
|
343
|
+
setSelectedReturnOption(null);
|
|
344
|
+
}, [setSelectedAvailability, setSelectedReturnOption]);
|
|
345
|
+
|
|
346
|
+
const {
|
|
1745
347
|
selectedPickupLocation,
|
|
348
|
+
pickupTimes,
|
|
349
|
+
hasAnyMostPopular,
|
|
350
|
+
getPickupCutoffEligibleTimesForDate,
|
|
351
|
+
isPublicSelectableAvailability,
|
|
352
|
+
calculateStaySummary,
|
|
353
|
+
computeItineraryDisplay,
|
|
354
|
+
computeItineraryDisplayForStorage,
|
|
355
|
+
} = useStandardBookingPickupItinerary({
|
|
356
|
+
product,
|
|
357
|
+
activeOptions,
|
|
358
|
+
optionsMap,
|
|
359
|
+
selectedDate,
|
|
1746
360
|
timesForSelectedDate,
|
|
361
|
+
availabilitiesByDate,
|
|
362
|
+
selectedAvailability,
|
|
363
|
+
selectedReturnOption,
|
|
364
|
+
pickupLocationId,
|
|
1747
365
|
pickupLocationSkipped,
|
|
1748
|
-
maxTimeOffsetMinutes,
|
|
1749
366
|
companyTimezone,
|
|
1750
|
-
]);
|
|
1751
|
-
|
|
1752
|
-
const isAvailabilityPickupAfterPublicBookingCutoff = useCallback(
|
|
1753
|
-
(availability: Availability, now: Date = bookingCutoffNow): boolean => {
|
|
1754
|
-
if (isAdmin) return true;
|
|
1755
|
-
const offsetMinutes = pickupLocationSkipped
|
|
1756
|
-
? 0
|
|
1757
|
-
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
1758
|
-
const availabilityTime = parseBookingCutoffDateTime(availability.dateTime);
|
|
1759
|
-
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
1760
|
-
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
1761
|
-
: availabilityTime;
|
|
1762
|
-
return isDateAfterPublicBookingCutoff(pickupTime, now, bookingCutoffMinutes);
|
|
1763
|
-
},
|
|
1764
|
-
[
|
|
1765
|
-
bookingCutoffNow,
|
|
1766
|
-
bookingCutoffMinutes,
|
|
1767
|
-
isAdmin,
|
|
1768
|
-
pickupLocationSkipped,
|
|
1769
|
-
selectedPickupLocation,
|
|
1770
|
-
],
|
|
1771
|
-
);
|
|
1772
|
-
|
|
1773
|
-
const getPickupCutoffEligibleTimesForDate = useCallback(
|
|
1774
|
-
(date: string): Availability[] => {
|
|
1775
|
-
const times = availabilitiesByDate[date] ?? [];
|
|
1776
|
-
return times.filter((avail) =>
|
|
1777
|
-
isAvailabilityPickupAfterPublicBookingCutoff(avail),
|
|
1778
|
-
);
|
|
1779
|
-
},
|
|
1780
|
-
[
|
|
1781
|
-
availabilitiesByDate,
|
|
1782
|
-
isAvailabilityPickupAfterPublicBookingCutoff,
|
|
1783
|
-
],
|
|
1784
|
-
);
|
|
1785
|
-
|
|
1786
|
-
const isPublicSelectableAvailability = useCallback(
|
|
1787
|
-
(availability: Availability, now: Date = bookingCutoffNow): boolean => {
|
|
1788
|
-
if (isAdmin) return true;
|
|
1789
|
-
if (!isAvailabilityAfterPublicBookingCutoff(availability, now, bookingCutoffMinutes)) return false;
|
|
1790
|
-
if (!isAvailabilityPickupAfterPublicBookingCutoff(availability, now)) return false;
|
|
1791
|
-
const availabilityDate = formatInTimeZone(
|
|
1792
|
-
parseBookingCutoffDateTime(availability.dateTime),
|
|
1793
|
-
companyTimezone,
|
|
1794
|
-
'yyyy-MM-dd',
|
|
1795
|
-
);
|
|
1796
|
-
const optionId = getAvailabilityOptionId(availability);
|
|
1797
|
-
return getPickupCutoffEligibleTimesForDate(availabilityDate).some(
|
|
1798
|
-
(candidate) =>
|
|
1799
|
-
candidate.dateTime === availability.dateTime &&
|
|
1800
|
-
getAvailabilityOptionId(candidate) === optionId,
|
|
1801
|
-
);
|
|
1802
|
-
},
|
|
1803
|
-
[
|
|
1804
|
-
bookingCutoffMinutes,
|
|
1805
|
-
bookingCutoffNow,
|
|
1806
|
-
companyTimezone,
|
|
1807
|
-
getPickupCutoffEligibleTimesForDate,
|
|
1808
|
-
isAvailabilityPickupAfterPublicBookingCutoff,
|
|
1809
|
-
isAdmin,
|
|
1810
|
-
],
|
|
1811
|
-
);
|
|
1812
|
-
|
|
1813
|
-
useEffect(() => {
|
|
1814
|
-
if (isAdmin || !selectedAvailability) return;
|
|
1815
|
-
const optionId = getAvailabilityOptionId(selectedAvailability);
|
|
1816
|
-
const stillSelectable = getPickupCutoffEligibleTimesForDate(selectedDate).some(
|
|
1817
|
-
(avail) =>
|
|
1818
|
-
avail.dateTime === selectedAvailability.dateTime &&
|
|
1819
|
-
getAvailabilityOptionId(avail) === optionId,
|
|
1820
|
-
);
|
|
1821
|
-
if (!stillSelectable) {
|
|
1822
|
-
setSelectedAvailability(null);
|
|
1823
|
-
setSelectedReturnOption(null);
|
|
1824
|
-
}
|
|
1825
|
-
}, [
|
|
1826
|
-
getPickupCutoffEligibleTimesForDate,
|
|
1827
367
|
isAdmin,
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
// Check if any pickup time has "most popular" tag (memoized for performance)
|
|
1833
|
-
const hasAnyMostPopular = useMemo(() => {
|
|
1834
|
-
if (pickupTimes.length <= 1) return false;
|
|
1835
|
-
return pickupTimes.some(t => {
|
|
1836
|
-
if (!t.productOptionId) return false;
|
|
1837
|
-
const opt = optionsMap.get(t.productOptionId);
|
|
1838
|
-
return opt?.mostPopular;
|
|
1839
|
-
});
|
|
1840
|
-
}, [pickupTimes, optionsMap]);
|
|
1841
|
-
|
|
1842
|
-
// Helper function to get effective itinerary based on selected date and overrides
|
|
1843
|
-
const getEffectiveItinerary = useCallback((option: typeof activeOptions[0], date: Date): typeof option.itinerary => {
|
|
1844
|
-
if (!option.itinerary) return undefined;
|
|
1845
|
-
|
|
1846
|
-
const monthDay = formatInTimeZone(date, companyTimezone, 'MM-dd');
|
|
1847
|
-
|
|
1848
|
-
// Check for date-specific override
|
|
1849
|
-
if (option.itineraryOverrides && option.itineraryOverrides.length > 0) {
|
|
1850
|
-
for (const override of option.itineraryOverrides) {
|
|
1851
|
-
if (monthDay >= override.startDate && monthDay <= override.endDate) {
|
|
1852
|
-
return override.itinerary;
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
return option.itinerary;
|
|
1858
|
-
}, [companyTimezone]);
|
|
1859
|
-
|
|
1860
|
-
// Helper function to calculate stay summary for a specific return option
|
|
1861
|
-
const calculateStaySummary = useCallback((returnDateTime: Date): string | null => {
|
|
1862
|
-
if (!selectedAvailability) return null;
|
|
1863
|
-
|
|
1864
|
-
const availabilityProductOptionId =
|
|
1865
|
-
getAvailabilityOptionId(selectedAvailability) ||
|
|
1866
|
-
(activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
|
|
1867
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
|
|
1868
|
-
if (!selectedOption) return null;
|
|
1869
|
-
|
|
1870
|
-
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
1871
|
-
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
1872
|
-
const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
1873
|
-
|
|
1874
|
-
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
1875
|
-
|
|
1876
|
-
const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
|
|
1877
|
-
let lastDepartureTime = tourStartTime;
|
|
1878
|
-
|
|
1879
|
-
const stays: Array<{ destinationName: string; durationHours: number }> = [];
|
|
1880
|
-
|
|
1881
|
-
itinerary.forEach((itineraryItem, index) => {
|
|
1882
|
-
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
1883
|
-
if (!destination) return;
|
|
1884
|
-
|
|
1885
|
-
const arrivalTime = new Date(
|
|
1886
|
-
lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000)
|
|
1887
|
-
);
|
|
1888
|
-
|
|
1889
|
-
const isLastDestination = index === itinerary.length - 1;
|
|
1890
|
-
|
|
1891
|
-
if (isLastDestination) {
|
|
1892
|
-
// For the last destination, calculate time from arrival to return time
|
|
1893
|
-
const timeDiffMs = returnDateTime.getTime() - arrivalTime.getTime();
|
|
1894
|
-
const timeDiffHours = timeDiffMs / (1000 * 60 * 60);
|
|
1895
|
-
|
|
1896
|
-
if (timeDiffHours > 0) {
|
|
1897
|
-
stays.push({
|
|
1898
|
-
destinationName: destination.name,
|
|
1899
|
-
durationHours: timeDiffHours,
|
|
1900
|
-
});
|
|
1901
|
-
}
|
|
1902
|
-
} else {
|
|
1903
|
-
// For non-last destinations (including first), use durationHours only if there's more than one stop
|
|
1904
|
-
if (itinerary.length > 1 && itineraryItem.durationHours && itineraryItem.durationHours > 0) {
|
|
1905
|
-
stays.push({
|
|
1906
|
-
destinationName: destination.name,
|
|
1907
|
-
durationHours: itineraryItem.durationHours,
|
|
1908
|
-
});
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
const departureTime = itineraryItem.durationHours && !isLastDestination
|
|
1913
|
-
? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
|
|
1914
|
-
: arrivalTime;
|
|
1915
|
-
|
|
1916
|
-
lastDepartureTime = departureTime;
|
|
1917
|
-
});
|
|
1918
|
-
|
|
1919
|
-
if (stays.length === 0) return null;
|
|
1920
|
-
|
|
1921
|
-
// Build summary string e.g. "2 hours at [destination] + 2 hours at [destination]"
|
|
1922
|
-
return stays.map(stay => {
|
|
1923
|
-
const hours = Math.floor(stay.durationHours);
|
|
1924
|
-
const minutes = Math.round((stay.durationHours - hours) * 60);
|
|
1925
|
-
let timeStr = '';
|
|
1926
|
-
if (hours === 0 && minutes === 0) {
|
|
1927
|
-
timeStr = '0 min';
|
|
1928
|
-
} else if (hours === 0) {
|
|
1929
|
-
timeStr = `${minutes} min`;
|
|
1930
|
-
} else if (minutes === 0) {
|
|
1931
|
-
timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
1932
|
-
} else {
|
|
1933
|
-
timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${minutes} min`;
|
|
1934
|
-
}
|
|
1935
|
-
return `${timeStr} at ${stay.destinationName}`;
|
|
1936
|
-
}).join(' + ');
|
|
1937
|
-
}, [selectedAvailability, activeOptions, product.destinations, getEffectiveItinerary]);
|
|
1938
|
-
|
|
1939
|
-
// Helper function to compute itinerary display for storage (returns same shape as "Your Itinerary" box)
|
|
1940
|
-
const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
|
|
1941
|
-
if (!selectedAvailability) return null;
|
|
1942
|
-
const availabilityProductOptionId =
|
|
1943
|
-
getAvailabilityOptionId(selectedAvailability) ||
|
|
1944
|
-
(activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
|
|
1945
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
|
|
1946
|
-
if (!selectedOption) return null;
|
|
1947
|
-
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
1948
|
-
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
1949
|
-
const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
1950
|
-
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
1951
|
-
|
|
1952
|
-
const itineraryItems: ItineraryDisplayStep[] = [];
|
|
1953
|
-
const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
|
|
1954
|
-
const pickupOffsetMinutes = pickupLocationSkipped ? 0 : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
1955
|
-
const actualPickupTime = pickupOffsetMinutes > 0
|
|
1956
|
-
? new Date(tourStartTime.getTime() + pickupOffsetMinutes * 60 * 1000)
|
|
1957
|
-
: tourStartTime;
|
|
1958
|
-
let pickupTimeDisplay: string;
|
|
1959
|
-
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
1960
|
-
const latestPickupTime = new Date(tourStartTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
|
|
1961
|
-
pickupTimeDisplay = `${formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a')}`;
|
|
1962
|
-
} else {
|
|
1963
|
-
pickupTimeDisplay = formatInTimeZone(actualPickupTime, companyTimezone, 'h:mm a');
|
|
1964
|
-
}
|
|
1965
|
-
const pickupPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
|
|
1966
|
-
itineraryItems.push({ stepType: StepType.pickup, time: pickupTimeDisplay, place: pickupPlace });
|
|
1967
|
-
let lastDepartureTime = tourStartTime;
|
|
1968
|
-
itinerary.forEach((itineraryItem, index) => {
|
|
1969
|
-
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
1970
|
-
if (!destination) return;
|
|
1971
|
-
const arrivalTime = new Date(lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000));
|
|
1972
|
-
itineraryItems.push({
|
|
1973
|
-
stepType: StepType.arrive,
|
|
1974
|
-
time: formatInTimeZone(arrivalTime, companyTimezone, 'h:mm a'),
|
|
1975
|
-
place: destination.name
|
|
1976
|
-
});
|
|
1977
|
-
const departureTime = itineraryItem.durationHours
|
|
1978
|
-
? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
|
|
1979
|
-
: arrivalTime;
|
|
1980
|
-
const hasMoreDestinations = index < itinerary.length - 1;
|
|
1981
|
-
if (itineraryItem.durationHours && hasMoreDestinations) {
|
|
1982
|
-
itineraryItems.push({
|
|
1983
|
-
stepType: StepType.depart,
|
|
1984
|
-
time: formatInTimeZone(departureTime, companyTimezone, 'h:mm a'),
|
|
1985
|
-
place: destination.name
|
|
1986
|
-
});
|
|
1987
|
-
}
|
|
1988
|
-
lastDepartureTime = departureTime;
|
|
1989
|
-
});
|
|
1990
|
-
// Return time: from selected return option (products with return selection) OR from itinerary's last departure (e.g. Emerald Lake shuttle with fixed multi-stop itinerary)
|
|
1991
|
-
const returnDateTime = selectedReturnOption
|
|
1992
|
-
? parseISO(selectedReturnOption.dateTime)
|
|
1993
|
-
: lastDepartureTime;
|
|
1994
|
-
const lastDestination = itinerary.length > 0 ? destinationMap.get(itinerary[itinerary.length - 1].destinationName) : null;
|
|
1995
|
-
const lastDestName = itinerary.length > 0 && product.destinations
|
|
1996
|
-
? (destinationMap.get(itinerary[itinerary.length - 1].destinationName)?.name ?? null)
|
|
1997
|
-
: null;
|
|
1998
|
-
const getDropOffMinutes = (loc: { travelMinutesFromDestination?: Record<string, number> }) => {
|
|
1999
|
-
if (lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null) return loc.travelMinutesFromDestination[lastDestName];
|
|
2000
|
-
return 0;
|
|
2001
|
-
};
|
|
2002
|
-
const hasDropOffEstimate = (loc: { travelMinutesFromDestination?: Record<string, number> }) =>
|
|
2003
|
-
Boolean(lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null);
|
|
2004
|
-
const dropOffPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation!.name;
|
|
2005
|
-
|
|
2006
|
-
if (selectedReturnOption) {
|
|
2007
|
-
itineraryItems.push({
|
|
2008
|
-
stepType: StepType.depart,
|
|
2009
|
-
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
2010
|
-
place: lastDestination?.name ?? 'the_destination'
|
|
2011
|
-
});
|
|
2012
|
-
} else if (lastDestination) {
|
|
2013
|
-
// No return options: show depart from last stop (e.g. Vermillion Lakes for Emerald Lake shuttle)
|
|
2014
|
-
itineraryItems.push({
|
|
2015
|
-
stepType: StepType.depart,
|
|
2016
|
-
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
2017
|
-
place: lastDestination.name
|
|
2018
|
-
});
|
|
2019
|
-
}
|
|
2020
|
-
// Add drop-off step (works for both return-option products and itinerary-only products like Emerald Lake)
|
|
2021
|
-
if (selectedPickupLocation && hasDropOffEstimate(selectedPickupLocation)) {
|
|
2022
|
-
const dropOffOffsetMinutes = getDropOffMinutes(selectedPickupLocation);
|
|
2023
|
-
const dropOffTime = new Date(returnDateTime.getTime() + dropOffOffsetMinutes * 60 * 1000);
|
|
2024
|
-
itineraryItems.push({
|
|
2025
|
-
stepType: StepType.drop_off,
|
|
2026
|
-
time: formatInTimeZone(dropOffTime, companyTimezone, 'h:mm a'),
|
|
2027
|
-
place: dropOffPlace
|
|
2028
|
-
});
|
|
2029
|
-
} else if (pickupLocationSkipped || !selectedPickupLocation) {
|
|
2030
|
-
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
2031
|
-
} else if (product.pickupLocations?.length) {
|
|
2032
|
-
const dropOffOffsets = product.pickupLocations.map(getDropOffMinutes);
|
|
2033
|
-
const [minOffset, maxOffset] = [Math.min(...dropOffOffsets), Math.max(...dropOffOffsets)];
|
|
2034
|
-
const earliestDropOff = new Date(returnDateTime.getTime() + minOffset * 60 * 1000);
|
|
2035
|
-
const latestDropOff = new Date(returnDateTime.getTime() + maxOffset * 60 * 1000);
|
|
2036
|
-
itineraryItems.push({
|
|
2037
|
-
stepType: StepType.drop_off,
|
|
2038
|
-
time: minOffset === maxOffset
|
|
2039
|
-
? formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')
|
|
2040
|
-
: `${formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestDropOff, companyTimezone, 'h:mm a')}`,
|
|
2041
|
-
place: dropOffPlace
|
|
2042
|
-
});
|
|
2043
|
-
} else {
|
|
2044
|
-
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
2045
|
-
}
|
|
2046
|
-
return itineraryItems;
|
|
2047
|
-
}, [selectedAvailability, selectedReturnOption, selectedPickupLocation, pickupLocationSkipped, activeOptions, product, getEffectiveItinerary, companyTimezone, maxTimeOffsetMinutes]);
|
|
2048
|
-
|
|
2049
|
-
/**
|
|
2050
|
-
* Itinerary for storage only (API/DB). When pickup is unknown, pickup step time is always a range
|
|
2051
|
-
* (e.g. "9 AM - 10:00 AM") so /manage and confirmation email show it; drop-off stays TBD.
|
|
2052
|
-
* UI during booking is unchanged (uses computeItineraryDisplay).
|
|
2053
|
-
*/
|
|
2054
|
-
const computeItineraryDisplayForStorage = useCallback((): ItineraryDisplayStep[] | null => {
|
|
2055
|
-
const base = computeItineraryDisplay();
|
|
2056
|
-
if (!base || base.length === 0) return base;
|
|
2057
|
-
const pickupUnknown = pickupLocationSkipped || (!selectedPickupLocation && product.pickupLocations && product.pickupLocations.length > 0);
|
|
2058
|
-
if (!pickupUnknown) return base;
|
|
2059
|
-
const tourStartTime = selectedAvailability ? parseISO(selectedAvailability.dateTime) : null;
|
|
2060
|
-
if (!tourStartTime) return base;
|
|
2061
|
-
const rangeMinutes = maxTimeOffsetMinutes > 0 ? maxTimeOffsetMinutes : 60;
|
|
2062
|
-
const latestPickupTime = new Date(tourStartTime.getTime() + rangeMinutes * 60 * 1000);
|
|
2063
|
-
const startStr = formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a');
|
|
2064
|
-
const endStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
2065
|
-
const pickupRangeTime = startStr === endStr ? startStr : `${startStr} - ${endStr}`;
|
|
2066
|
-
return base.map((step, i) =>
|
|
2067
|
-
i === 0 && step.stepType === StepType.pickup && step.place === 'your_pickup_location'
|
|
2068
|
-
? { ...step, time: pickupRangeTime }
|
|
2069
|
-
: step
|
|
2070
|
-
);
|
|
2071
|
-
}, [computeItineraryDisplay, pickupLocationSkipped, selectedPickupLocation, product.pickupLocations, selectedAvailability, companyTimezone, maxTimeOffsetMinutes]);
|
|
2072
|
-
|
|
2073
|
-
// Product has fees from config (e.g. product-fees.json); API sends these in pricingConfig.fees
|
|
2074
|
-
const hasFees = useMemo(() =>
|
|
2075
|
-
Boolean(pricingConfig?.fees && Object.keys(pricingConfig.fees).length > 0 && Object.values(pricingConfig.fees).some(v => (v?.feePerPerson ?? 0) > 0)),
|
|
2076
|
-
[pricingConfig?.fees]
|
|
2077
|
-
);
|
|
2078
|
-
|
|
2079
|
-
const returnOptionsWithFloor = useMemo(
|
|
2080
|
-
() => selectedAvailability?.returnOptions ?? [],
|
|
2081
|
-
[selectedAvailability?.returnOptions],
|
|
2082
|
-
);
|
|
2083
|
-
|
|
2084
|
-
const selectedReturnOptionWithFloor = selectedReturnOption;
|
|
2085
|
-
|
|
2086
|
-
const returnOptionForOrderSummary = selectedReturnOptionWithFloor;
|
|
2087
|
-
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2088
|
-
if (!selectedAvailability) return 0;
|
|
2089
|
-
return Math.max(0, selectedAvailability.vacancies ?? 0);
|
|
2090
|
-
}, [selectedAvailability]);
|
|
2091
|
-
const effectiveSelectedReturnVacancies = useMemo(() => {
|
|
2092
|
-
if (!selectedReturnOption) return null;
|
|
2093
|
-
return Math.max(0, selectedReturnOption.vacancies ?? 0);
|
|
2094
|
-
}, [selectedReturnOption]);
|
|
2095
|
-
|
|
2096
|
-
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2097
|
-
const pricing = useMemo(
|
|
2098
|
-
() =>
|
|
2099
|
-
buildPricingFromAvailability(
|
|
2100
|
-
selectedAvailability,
|
|
2101
|
-
activeOptions,
|
|
2102
|
-
precomputedPricesByOption,
|
|
2103
|
-
currency,
|
|
2104
|
-
pricingConfig,
|
|
2105
|
-
hasFees,
|
|
2106
|
-
isSimplifiedPricingView,
|
|
2107
|
-
),
|
|
2108
|
-
[selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
|
|
2109
|
-
);
|
|
2110
|
-
|
|
2111
|
-
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2112
|
-
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
2113
|
-
if (!pricingConfig) return null;
|
|
2114
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''));
|
|
2115
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
2116
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
2117
|
-
return computePriceBreakdown(
|
|
2118
|
-
pricingConfig,
|
|
2119
|
-
currency,
|
|
2120
|
-
priceCAD,
|
|
2121
|
-
basePriceCAD,
|
|
2122
|
-
hasFees,
|
|
2123
|
-
appliedAdjustments,
|
|
2124
|
-
undefined,
|
|
2125
|
-
baseInDisplayCurrency,
|
|
2126
|
-
isPublicMode
|
|
2127
|
-
);
|
|
2128
|
-
}, [pricingConfig, currency, hasFees, activeOptions, selectedAvailability, isSimplifiedPricingView]);
|
|
2129
|
-
|
|
2130
|
-
// Order summary from mid-layer; UI only displays these values (no calculations).
|
|
2131
|
-
const orderSummary: OrderSummary = useMemo(
|
|
2132
|
-
() =>
|
|
2133
|
-
computeOrderSummary(
|
|
2134
|
-
quantities,
|
|
2135
|
-
pricing,
|
|
2136
|
-
returnOptionForOrderSummary,
|
|
2137
|
-
pricingConfig ?? null,
|
|
2138
|
-
currency,
|
|
2139
|
-
hasFees,
|
|
2140
|
-
cancellationPolicyId
|
|
2141
|
-
),
|
|
2142
|
-
[quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2143
|
-
);
|
|
2144
|
-
|
|
2145
|
-
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2146
|
-
/** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
|
|
2147
|
-
const effectivePartySizeCap = useMemo(() => {
|
|
2148
|
-
if (!selectedAvailability) return 0;
|
|
2149
|
-
const outbound = Math.max(0, selectedAvailability.vacancies ?? 0);
|
|
2150
|
-
if (selectedReturnOption == null) return outbound;
|
|
2151
|
-
const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0);
|
|
2152
|
-
return Math.min(outbound, returnCap);
|
|
2153
|
-
}, [selectedAvailability, selectedReturnOption]);
|
|
2154
|
-
|
|
2155
|
-
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
2156
|
-
/** Label for display when policy may be forced by promo (not in pricingConfig list). */
|
|
2157
|
-
const effectiveCancellationPolicyLabel = selectedCancellationPolicy?.label ?? forcedCancellationPolicy?.label ?? (t('booking.flexibleCancellation') || 'Flexible cancellation');
|
|
368
|
+
bookingCutoffNow,
|
|
369
|
+
bookingCutoffMinutes,
|
|
370
|
+
onSelectedAvailabilityExpired: handleSelectedAvailabilityExpired,
|
|
371
|
+
});
|
|
2158
372
|
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
if (isAdmin || !selectedAvailability) return;
|
|
2162
|
-
const cap = effectivePartySizeCap;
|
|
2163
|
-
if (totalQuantity <= cap) return;
|
|
2164
|
-
const over = totalQuantity - cap;
|
|
2165
|
-
setQuantities((prev) => {
|
|
2166
|
-
const next = { ...prev };
|
|
2167
|
-
let remaining = over;
|
|
2168
|
-
const cats = Object.keys(next)
|
|
2169
|
-
.filter((c) => (next[c] ?? 0) > 0)
|
|
2170
|
-
.sort((a, b) => (next[b] ?? 0) - (next[a] ?? 0));
|
|
2171
|
-
for (const cat of cats) {
|
|
2172
|
-
if (remaining <= 0) break;
|
|
2173
|
-
const minQ = 0;
|
|
2174
|
-
const q = next[cat] ?? 0;
|
|
2175
|
-
const reducible = Math.max(0, q - minQ);
|
|
2176
|
-
const dec = Math.min(reducible, remaining);
|
|
2177
|
-
next[cat] = q - dec;
|
|
2178
|
-
remaining -= dec;
|
|
2179
|
-
}
|
|
2180
|
-
return next;
|
|
2181
|
-
});
|
|
2182
|
-
}, [
|
|
2183
|
-
effectivePartySizeCap,
|
|
2184
|
-
totalQuantity,
|
|
373
|
+
useStandardBookingAutoSelections({
|
|
374
|
+
selectedDate,
|
|
2185
375
|
selectedAvailability,
|
|
376
|
+
selectedReturnOption,
|
|
377
|
+
timesForSelectedDateLength: timesForSelectedDate.length,
|
|
378
|
+
timesForSelectedDateSelectionKey,
|
|
379
|
+
activeOptions,
|
|
380
|
+
companyTimezone,
|
|
381
|
+
getPickupCutoffEligibleTimesForDate,
|
|
382
|
+
setSelectedAvailability,
|
|
383
|
+
setSelectedReturnOption,
|
|
384
|
+
setSelectedDate,
|
|
385
|
+
setError,
|
|
386
|
+
initialReturnAvailabilityId: initialValues?.returnAvailabilityId,
|
|
387
|
+
initialReturnDateTime: initialValues?.returnDateTime,
|
|
388
|
+
autoSelectFirstAvailableDate: flowUi?.autoSelectFirstAvailableDate === true,
|
|
389
|
+
isPartialLaunch,
|
|
390
|
+
initialDateTime: initialValues?.dateTime,
|
|
391
|
+
dates,
|
|
2186
392
|
isAdmin,
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
: 0;
|
|
2200
|
-
sum += (basePrice + variantAdjustment) * (sel.quantity ?? 1);
|
|
2201
|
-
}
|
|
2202
|
-
return sum;
|
|
2203
|
-
}, [addOnSelections, addOns]);
|
|
2204
|
-
|
|
2205
|
-
/** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal. */
|
|
2206
|
-
const checkoutReturnLineAmount = returnPriceAdjustment;
|
|
2207
|
-
|
|
2208
|
-
const effectiveSubtotalBeforeAddOns = subtotal;
|
|
2209
|
-
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
393
|
+
suppressCalendarDateScroll,
|
|
394
|
+
contentRef,
|
|
395
|
+
useWindowScroll,
|
|
396
|
+
autoSelectFirstHighlightedPickup: flowUi?.autoSelectFirstHighlightedPickup === true,
|
|
397
|
+
highlightedPickupLocationIds,
|
|
398
|
+
pickupLocationId,
|
|
399
|
+
pickupLocationSkipped,
|
|
400
|
+
initialPickupLocationId: initialValues?.pickupLocationId,
|
|
401
|
+
pickupLocations: product.pickupLocations,
|
|
402
|
+
setPickupLocationId,
|
|
403
|
+
setPickupLocationSkipped,
|
|
404
|
+
});
|
|
2210
405
|
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
Object.entries(quantities)
|
|
2215
|
-
.filter(([, n]) => (n ?? 0) > 0)
|
|
2216
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
2217
|
-
.map(([c, n]) => `${c}:${n}`)
|
|
2218
|
-
.join('|'),
|
|
2219
|
-
[quantities]
|
|
406
|
+
const promoValidationQuantity = useMemo(
|
|
407
|
+
() => Object.values(quantities).reduce((total, quantity) => total + quantity, 0),
|
|
408
|
+
[quantities],
|
|
2220
409
|
);
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
}, [feeLineItems, addOnSelections, addOns]);
|
|
2240
|
-
|
|
2241
|
-
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2242
|
-
if (!selectedAvailability) return [];
|
|
2243
|
-
const returnLineAmount = checkoutReturnLineAmount;
|
|
2244
|
-
const showReturnLine =
|
|
2245
|
-
Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
|
|
2246
|
-
return [
|
|
2247
|
-
...ticketLineItems.map((line): PriceSummaryLine => {
|
|
2248
|
-
const rate = pricing.find((r) => r.category === line.category);
|
|
2249
|
-
const breakdown = getPriceBreakdown(
|
|
2250
|
-
line.category,
|
|
2251
|
-
rate?.priceCAD ?? 0,
|
|
2252
|
-
rate?.baseInDisplayCurrency,
|
|
2253
|
-
rate?.appliedAdjustments ?? [],
|
|
2254
|
-
);
|
|
2255
|
-
return {
|
|
2256
|
-
kind: 'ticket',
|
|
2257
|
-
category: line.category,
|
|
2258
|
-
qty: line.qty,
|
|
2259
|
-
itemTotal: line.itemTotal,
|
|
2260
|
-
breakdown,
|
|
2261
|
-
};
|
|
2262
|
-
}),
|
|
2263
|
-
...(showReturnLine
|
|
2264
|
-
? [
|
|
2265
|
-
{
|
|
2266
|
-
kind: 'line' as const,
|
|
2267
|
-
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
2268
|
-
amount: returnLineAmount,
|
|
2269
|
-
type: 'return',
|
|
2270
|
-
},
|
|
2271
|
-
]
|
|
2272
|
-
: []),
|
|
2273
|
-
...(cancellationPolicyFee > 0 && (selectedCancellationPolicy || forcedCancellationPolicy)
|
|
2274
|
-
? [
|
|
2275
|
-
{
|
|
2276
|
-
kind: 'line' as const,
|
|
2277
|
-
label: effectiveCancellationPolicyLabel,
|
|
2278
|
-
amount: cancellationPolicyFee,
|
|
2279
|
-
type: 'cancellation',
|
|
2280
|
-
},
|
|
2281
|
-
]
|
|
2282
|
-
: []),
|
|
2283
|
-
...feeLineItemsWithAddOns.map((fee) => {
|
|
2284
|
-
const isMoraineLakeRoadAccessFee =
|
|
2285
|
-
fee.name.toLowerCase().includes('moraine') &&
|
|
2286
|
-
(fee.name.toLowerCase().includes('access') ||
|
|
2287
|
-
fee.name.toLowerCase().includes('road') ||
|
|
2288
|
-
fee.name.toLowerCase().includes('license'));
|
|
2289
|
-
return {
|
|
2290
|
-
kind: 'line' as const,
|
|
2291
|
-
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2292
|
-
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2293
|
-
: fee.name,
|
|
2294
|
-
amount: fee.totalAmount,
|
|
2295
|
-
type: 'fee',
|
|
2296
|
-
tooltip: isMoraineLakeRoadAccessFee
|
|
2297
|
-
? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
|
|
2298
|
-
: undefined,
|
|
2299
|
-
};
|
|
2300
|
-
}),
|
|
2301
|
-
];
|
|
2302
|
-
}, [
|
|
410
|
+
const clearFetchedAvailabilityRanges = useCallback(() => {
|
|
411
|
+
fetchedRangesRef.current = [];
|
|
412
|
+
}, [fetchedRangesRef]);
|
|
413
|
+
const {
|
|
414
|
+
promoCodeError,
|
|
415
|
+
promoCodeValidating,
|
|
416
|
+
forcedCancellationPolicy,
|
|
417
|
+
cancellationPolicyRef,
|
|
418
|
+
handlePromoInputChange,
|
|
419
|
+
handleApplyPromo,
|
|
420
|
+
handleRemovePromo,
|
|
421
|
+
} = useStandardBookingPromoController({
|
|
422
|
+
autoAppliedPromoCode,
|
|
423
|
+
initialPromoCode: initialValues?.promoCode,
|
|
424
|
+
promoCodeInput,
|
|
425
|
+
setPromoCodeInput,
|
|
426
|
+
appliedPromoCode,
|
|
427
|
+
setAppliedPromoCode,
|
|
2303
428
|
selectedAvailability,
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
totalQuantity,
|
|
429
|
+
totalQuantity: promoValidationQuantity,
|
|
430
|
+
productCompanyId: product.companyId,
|
|
431
|
+
productId: product.productId,
|
|
432
|
+
clearFetchedAvailabilityRanges,
|
|
433
|
+
setCancellationPolicyId,
|
|
2310
434
|
t,
|
|
2311
|
-
cancellationPolicyFee,
|
|
2312
|
-
selectedCancellationPolicy,
|
|
2313
|
-
forcedCancellationPolicy,
|
|
2314
|
-
effectiveCancellationPolicyLabel,
|
|
2315
|
-
feeLineItemsWithAddOns,
|
|
2316
|
-
feeLineItems,
|
|
2317
|
-
]);
|
|
2318
|
-
|
|
2319
|
-
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
2320
|
-
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
2321
|
-
const [isGiftCard, setIsGiftCard] = useState(false);
|
|
2322
|
-
const [isVoucher, setIsVoucher] = useState(false);
|
|
2323
|
-
/** Monotonic id per discount fetch; stale responses are ignored. */
|
|
2324
|
-
const promoDiscountFetchGenerationRef = useRef(0);
|
|
2325
|
-
/** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
|
|
2326
|
-
const promoDiscountParamsRef = useRef({
|
|
2327
|
-
selectedAvailability: null as Availability | null,
|
|
2328
|
-
ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
|
|
2329
|
-
effectiveSubtotal: 0,
|
|
2330
|
-
displayedProductFeeTotal: 0,
|
|
2331
|
-
appliedPromoCode: null as string | null,
|
|
2332
435
|
});
|
|
2333
436
|
|
|
2334
|
-
const
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
appliedPromoCode,
|
|
2342
|
-
companyId,
|
|
2343
|
-
product.productId,
|
|
2344
|
-
optionId,
|
|
2345
|
-
selectedAvailability.dateTime,
|
|
2346
|
-
selectedAvailability.availabilityId ?? '',
|
|
2347
|
-
currency,
|
|
2348
|
-
quantitiesSignature,
|
|
2349
|
-
ticketLineItems
|
|
2350
|
-
.map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
|
|
2351
|
-
.sort()
|
|
2352
|
-
.join('|'),
|
|
2353
|
-
String(Math.round(feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
|
|
2354
|
-
String(Math.round(effectiveSubtotal * 100)),
|
|
2355
|
-
].join('::');
|
|
2356
|
-
}, [
|
|
2357
|
-
appliedPromoCode,
|
|
2358
|
-
selectedAvailability?.dateTime,
|
|
2359
|
-
selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : '',
|
|
2360
|
-
selectedAvailability?.availabilityId,
|
|
437
|
+
const {
|
|
438
|
+
returnOptionsWithFloor,
|
|
439
|
+
selectedReturnOptionWithFloor,
|
|
440
|
+
effectiveSelectedPickupVacancies,
|
|
441
|
+
effectiveSelectedReturnVacancies,
|
|
442
|
+
pricing,
|
|
443
|
+
getPriceBreakdown,
|
|
2361
444
|
totalQuantity,
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
quantitiesSignature,
|
|
2366
|
-
ticketLineItems,
|
|
445
|
+
subtotal,
|
|
446
|
+
tax,
|
|
447
|
+
totalFromSummary,
|
|
2367
448
|
feeLineItems,
|
|
449
|
+
cancellationPolicyFee,
|
|
450
|
+
isTaxIncludedInPrice,
|
|
451
|
+
ticketLineItems,
|
|
452
|
+
effectivePartySizeCap,
|
|
453
|
+
effectiveCancellationPolicyLabel,
|
|
454
|
+
addOnTotal,
|
|
455
|
+
checkoutReturnLineAmount,
|
|
2368
456
|
effectiveSubtotal,
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
457
|
+
feeLineItemsWithAddOns,
|
|
458
|
+
checkoutPriceSummaryLines,
|
|
459
|
+
promoDiscountAmount,
|
|
460
|
+
isGiftCard,
|
|
461
|
+
effectivePromoDiscountAmount,
|
|
462
|
+
effectiveTax,
|
|
463
|
+
totalPrice,
|
|
464
|
+
priceSummaryAugmentation,
|
|
465
|
+
displayCheckoutPriceSummaryLines,
|
|
466
|
+
displaySubtotal,
|
|
467
|
+
displayTotalPrice,
|
|
468
|
+
} = useStandardBookingPriceSummary({
|
|
469
|
+
selectedAvailability,
|
|
470
|
+
selectedReturnOption,
|
|
471
|
+
activeOptions,
|
|
472
|
+
precomputedPricesByOption,
|
|
473
|
+
currency,
|
|
474
|
+
pricingConfig,
|
|
475
|
+
isSimplifiedPricingView,
|
|
476
|
+
quantities,
|
|
477
|
+
cancellationPolicyId,
|
|
478
|
+
forcedCancellationPolicy,
|
|
479
|
+
addOnSelections,
|
|
480
|
+
addOns,
|
|
481
|
+
t,
|
|
2376
482
|
appliedPromoCode,
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
return;
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
let cancelled = false;
|
|
2388
|
-
const debounceMs = 120;
|
|
2389
|
-
const timer = window.setTimeout(() => {
|
|
2390
|
-
if (cancelled) return;
|
|
2391
|
-
const {
|
|
2392
|
-
selectedAvailability: sel,
|
|
2393
|
-
ticketLineItems: lines,
|
|
2394
|
-
effectiveSubtotal: sub,
|
|
2395
|
-
displayedProductFeeTotal,
|
|
2396
|
-
appliedPromoCode: code,
|
|
2397
|
-
} = promoDiscountParamsRef.current;
|
|
2398
|
-
if (!code || !sel) return;
|
|
2399
|
-
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
2400
|
-
const optionId = getAvailabilityOptionId(sel);
|
|
2401
|
-
if (!companyId || !optionId) return;
|
|
2402
|
-
const items = lines.map((l) => ({ category: l.category, qty: l.qty }));
|
|
2403
|
-
if (items.length === 0) return;
|
|
2404
|
-
|
|
2405
|
-
const generation = ++promoDiscountFetchGenerationRef.current;
|
|
2406
|
-
getPromoDiscount(
|
|
2407
|
-
code,
|
|
2408
|
-
companyId,
|
|
2409
|
-
product.productId,
|
|
2410
|
-
optionId,
|
|
2411
|
-
currency,
|
|
2412
|
-
items,
|
|
2413
|
-
sel.dateTime,
|
|
2414
|
-
sub,
|
|
2415
|
-
{
|
|
2416
|
-
availabilityCount: sel.vacancies,
|
|
2417
|
-
totalCapacity: sel.totalCapacity,
|
|
2418
|
-
displayedTicketLines: lines,
|
|
2419
|
-
displayedProductFeeTotal,
|
|
2420
|
-
}
|
|
2421
|
-
)
|
|
2422
|
-
.then((res) => {
|
|
2423
|
-
if (cancelled) return;
|
|
2424
|
-
if (generation !== promoDiscountFetchGenerationRef.current) return;
|
|
2425
|
-
setPromoDiscountAmount(res.discount ?? 0);
|
|
2426
|
-
setIsGiftCard(res.isGiftCard ?? false);
|
|
2427
|
-
setIsVoucher(res.isVoucher ?? false);
|
|
2428
|
-
})
|
|
2429
|
-
.catch(() => {
|
|
2430
|
-
if (cancelled) return;
|
|
2431
|
-
if (generation !== promoDiscountFetchGenerationRef.current) return;
|
|
2432
|
-
setPromoDiscountAmount(0);
|
|
2433
|
-
setIsGiftCard(false);
|
|
2434
|
-
setIsVoucher(false);
|
|
2435
|
-
});
|
|
2436
|
-
}, debounceMs);
|
|
2437
|
-
|
|
2438
|
-
return () => {
|
|
2439
|
-
cancelled = true;
|
|
2440
|
-
window.clearTimeout(timer);
|
|
2441
|
-
};
|
|
2442
|
-
}, [promoDiscountFetchKey, product.companyId, product.productId, currency]);
|
|
2443
|
-
|
|
2444
|
-
// Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
|
|
2445
|
-
// Vouchers and gift cards: tax on full subtotal (voucher discount includes tax on free portion; gift card is payment).
|
|
2446
|
-
const effectivePromoDiscountAmount = promoDiscountAmount > 0 ? promoDiscountAmount : 0;
|
|
2447
|
-
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
|
|
2448
|
-
const effectiveTax =
|
|
2449
|
-
effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
2450
|
-
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
2451
|
-
: taxOnSubtotal;
|
|
2452
|
-
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
2453
|
-
const priceSummaryAugmentation = useMemo(
|
|
2454
|
-
() =>
|
|
2455
|
-
augmentPriceSummary
|
|
2456
|
-
? augmentPriceSummary({
|
|
2457
|
-
selectedDate: selectedDate || null,
|
|
2458
|
-
selectedAvailability,
|
|
2459
|
-
itineraryItems: computeItineraryDisplay(),
|
|
2460
|
-
priceSummaryLines: checkoutPriceSummaryLines,
|
|
2461
|
-
subtotal: effectiveSubtotal,
|
|
2462
|
-
tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
|
|
2463
|
-
total: totalPrice,
|
|
2464
|
-
currency,
|
|
2465
|
-
})
|
|
2466
|
-
: null,
|
|
2467
|
-
[
|
|
2468
|
-
augmentPriceSummary,
|
|
2469
|
-
selectedDate,
|
|
2470
|
-
selectedAvailability,
|
|
2471
|
-
checkoutPriceSummaryLines,
|
|
2472
|
-
effectiveSubtotal,
|
|
2473
|
-
effectivePromoDiscountAmount,
|
|
2474
|
-
effectiveTax,
|
|
2475
|
-
tax,
|
|
2476
|
-
totalPrice,
|
|
2477
|
-
currency,
|
|
2478
|
-
]
|
|
2479
|
-
);
|
|
2480
|
-
const displayCheckoutPriceSummaryLines =
|
|
2481
|
-
priceSummaryAugmentation?.lines ?? checkoutPriceSummaryLines;
|
|
2482
|
-
const displaySubtotal =
|
|
2483
|
-
effectiveSubtotal + (priceSummaryAugmentation?.subtotalAdjustment ?? 0);
|
|
2484
|
-
const displayTotalPrice =
|
|
2485
|
-
totalPrice + (priceSummaryAugmentation?.totalAdjustment ?? 0);
|
|
2486
|
-
const augmentedModalFeeLineItems = useMemo((): OrderSummaryFeeLine[] => {
|
|
2487
|
-
const externalLines =
|
|
2488
|
-
priceSummaryAugmentation?.lines
|
|
2489
|
-
?.slice(checkoutPriceSummaryLines.length)
|
|
2490
|
-
.flatMap((line) =>
|
|
2491
|
-
line.kind === 'line'
|
|
2492
|
-
? [
|
|
2493
|
-
{
|
|
2494
|
-
name: line.label,
|
|
2495
|
-
totalAmount: line.amount,
|
|
2496
|
-
showQuantityLabel: false,
|
|
2497
|
-
},
|
|
2498
|
-
]
|
|
2499
|
-
: []
|
|
2500
|
-
) ?? [];
|
|
2501
|
-
return [...feeLineItemsWithAddOns, ...externalLines];
|
|
2502
|
-
}, [checkoutPriceSummaryLines.length, feeLineItemsWithAddOns, priceSummaryAugmentation?.lines]);
|
|
483
|
+
productCompanyId: product.companyId,
|
|
484
|
+
envCompanyId: env.COMPANY_ID,
|
|
485
|
+
productId: product.productId,
|
|
486
|
+
selectedDate,
|
|
487
|
+
computeItineraryDisplay,
|
|
488
|
+
augmentPriceSummary,
|
|
489
|
+
});
|
|
2503
490
|
|
|
491
|
+
useBookingQuantityCapTrim({
|
|
492
|
+
isAdmin,
|
|
493
|
+
selectedAvailability,
|
|
494
|
+
effectivePartySizeCap,
|
|
495
|
+
totalQuantity,
|
|
496
|
+
setQuantities,
|
|
497
|
+
});
|
|
2504
498
|
const changeCheckoutButtonLabel = undefined;
|
|
2505
499
|
|
|
2506
500
|
const deferredInvoiceSubmitLabel = flowUi?.partnerDeferredInvoice
|
|
@@ -2508,6 +502,45 @@ export function NewBookingFlow({
|
|
|
2508
502
|
: undefined;
|
|
2509
503
|
|
|
2510
504
|
const checkoutFormError = error || '';
|
|
505
|
+
const checkoutFormSubtotal =
|
|
506
|
+
subtotal !== totalFromSummary ||
|
|
507
|
+
effectivePromoDiscountAmount > 0 ||
|
|
508
|
+
addOnTotal > 0 ||
|
|
509
|
+
(priceSummaryAugmentation?.subtotalAdjustment ?? 0) !== 0
|
|
510
|
+
? displaySubtotal
|
|
511
|
+
: undefined;
|
|
512
|
+
const checkoutFormTaxAmount =
|
|
513
|
+
!isTaxIncludedInPrice &&
|
|
514
|
+
(effectivePromoDiscountAmount > 0 ? effectiveTax : tax) > 0
|
|
515
|
+
? (effectivePromoDiscountAmount > 0 ? effectiveTax : tax)
|
|
516
|
+
: 0;
|
|
517
|
+
|
|
518
|
+
const handleCheckoutPickupLocationSelect = useCallback((locationId: string | null) => {
|
|
519
|
+
setPickupLocationId(locationId);
|
|
520
|
+
setError('');
|
|
521
|
+
if (locationId === null && pickupLocationSkipped) {
|
|
522
|
+
setPickupLocationSkipped(false);
|
|
523
|
+
} else if (locationId !== null) {
|
|
524
|
+
setPickupLocationSkipped(false);
|
|
525
|
+
}
|
|
526
|
+
}, [pickupLocationSkipped]);
|
|
527
|
+
|
|
528
|
+
const handleCheckoutPickupSkip = useCallback(() => {
|
|
529
|
+
if (hidePickupSkipOption) return;
|
|
530
|
+
setPickupLocationSkipped(true);
|
|
531
|
+
setPickupLocationId(null);
|
|
532
|
+
setError('');
|
|
533
|
+
}, [hidePickupSkipOption]);
|
|
534
|
+
|
|
535
|
+
const handleCheckoutPickupChange = useCallback(() => {
|
|
536
|
+
setPickupLocationId(null);
|
|
537
|
+
setPickupLocationSkipped(false);
|
|
538
|
+
}, []);
|
|
539
|
+
|
|
540
|
+
const handleTermsChange = useCallback((checked: boolean) => {
|
|
541
|
+
setTermsAccepted(checked);
|
|
542
|
+
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
543
|
+
}, []);
|
|
2511
544
|
|
|
2512
545
|
useEffect(() => {
|
|
2513
546
|
if (!onPricePreviewChange) return;
|
|
@@ -2532,118 +565,10 @@ export function NewBookingFlow({
|
|
|
2532
565
|
totalPrice,
|
|
2533
566
|
]);
|
|
2534
567
|
|
|
2535
|
-
// Auto-select pickup when date is selected (or when slots load after date pick).
|
|
2536
|
-
useEffect(() => {
|
|
2537
|
-
if (!selectedDate || timesForSelectedDate.length === 0) return;
|
|
2538
|
-
if (
|
|
2539
|
-
selectedAvailability &&
|
|
2540
|
-
availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)
|
|
2541
|
-
) {
|
|
2542
|
-
return;
|
|
2543
|
-
}
|
|
2544
|
-
const toSelect = pickDefaultAvailabilityForTimes(
|
|
2545
|
-
getPickupCutoffEligibleTimesForDate(selectedDate),
|
|
2546
|
-
activeOptions,
|
|
2547
|
-
);
|
|
2548
|
-
if (toSelect) {
|
|
2549
|
-
setSelectedAvailability(toSelect);
|
|
2550
|
-
setError('');
|
|
2551
|
-
}
|
|
2552
|
-
}, [
|
|
2553
|
-
selectedDate,
|
|
2554
|
-
timesForSelectedDateSelectionKey,
|
|
2555
|
-
activeOptions,
|
|
2556
|
-
selectedAvailability,
|
|
2557
|
-
companyTimezone,
|
|
2558
|
-
getPickupCutoffEligibleTimesForDate,
|
|
2559
|
-
]);
|
|
2560
|
-
|
|
2561
568
|
// Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
|
|
2562
569
|
// changeByCurrency, feesByCurrency, precomputedPrices, etc.) in one response; we just
|
|
2563
570
|
// re-render with the new currency and pick the right values.
|
|
2564
571
|
|
|
2565
|
-
// Sync selectedAvailability when the availabilities list changes (e.g. after refetch for new date range)
|
|
2566
|
-
useEffect(() => {
|
|
2567
|
-
if (selectedAvailability && availabilities.length > 0) {
|
|
2568
|
-
const updatedAvailability = availabilities.find(
|
|
2569
|
-
avail =>
|
|
2570
|
-
avail.dateTime === selectedAvailability.dateTime &&
|
|
2571
|
-
getAvailabilityOptionId(avail) === getAvailabilityOptionId(selectedAvailability)
|
|
2572
|
-
);
|
|
2573
|
-
if (updatedAvailability && shouldSyncSelectedAvailability(selectedAvailability, updatedAvailability)) {
|
|
2574
|
-
setSelectedAvailability(updatedAvailability);
|
|
2575
|
-
|
|
2576
|
-
// Also update selectedReturnOption if it exists
|
|
2577
|
-
if (selectedReturnOption && updatedAvailability.returnOptions) {
|
|
2578
|
-
const updatedReturnOption = updatedAvailability.returnOptions.find(
|
|
2579
|
-
opt => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
2580
|
-
);
|
|
2581
|
-
if (updatedReturnOption) {
|
|
2582
|
-
setSelectedReturnOption(updatedReturnOption);
|
|
2583
|
-
}
|
|
2584
|
-
}
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2588
|
-
}, [availabilities]); // Update when availabilities change (selectedAvailability/selectedReturnOption intentionally excluded to avoid loops)
|
|
2589
|
-
|
|
2590
|
-
// Auto-select return option when outbound is selected (partner prefill may hint return id/datetime).
|
|
2591
|
-
useEffect(() => {
|
|
2592
|
-
if (selectedAvailability?.returnOptions && selectedAvailability.returnOptions.length > 0 && !selectedReturnOption) {
|
|
2593
|
-
const sorted = [...selectedAvailability.returnOptions].sort(
|
|
2594
|
-
(a, b) => parseISO(a.dateTime).getTime() - parseISO(b.dateTime).getTime()
|
|
2595
|
-
);
|
|
2596
|
-
const initialReturnIdForSelect = initialValues?.returnAvailabilityId?.trim();
|
|
2597
|
-
const initialReturnDtForSelect = initialValues?.returnDateTime?.trim();
|
|
2598
|
-
|
|
2599
|
-
const preferBooked = initialReturnIdForSelect
|
|
2600
|
-
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
|
|
2601
|
-
: undefined;
|
|
2602
|
-
const preferByDateTime =
|
|
2603
|
-
initialReturnDtForSelect && !preferBooked
|
|
2604
|
-
? sorted.find((opt) => {
|
|
2605
|
-
try {
|
|
2606
|
-
return (
|
|
2607
|
-
parseAvailabilityDateTime(opt.dateTime).getTime() ===
|
|
2608
|
-
parseAvailabilityDateTime(initialReturnDtForSelect).getTime()
|
|
2609
|
-
);
|
|
2610
|
-
} catch {
|
|
2611
|
-
return false;
|
|
2612
|
-
}
|
|
2613
|
-
})
|
|
2614
|
-
: undefined;
|
|
2615
|
-
const firstAvailable = sorted.find((opt) => opt.vacancies > 0);
|
|
2616
|
-
const toSelect = preferBooked ?? preferByDateTime ?? firstAvailable;
|
|
2617
|
-
if (toSelect) {
|
|
2618
|
-
setSelectedReturnOption(toSelect);
|
|
2619
|
-
}
|
|
2620
|
-
}
|
|
2621
|
-
}, [
|
|
2622
|
-
selectedAvailability,
|
|
2623
|
-
selectedReturnOption,
|
|
2624
|
-
initialValues?.returnAvailabilityId,
|
|
2625
|
-
initialValues?.returnDateTime,
|
|
2626
|
-
]);
|
|
2627
|
-
|
|
2628
|
-
// Fetch add-ons when availability (product option) is selected; clear selections when option changes
|
|
2629
|
-
const availabilityProductOptionId = selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null;
|
|
2630
|
-
const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
|
|
2631
|
-
useEffect(() => {
|
|
2632
|
-
if (!availabilityProductOptionId || !product.companyId) {
|
|
2633
|
-
setAddOns([]);
|
|
2634
|
-
setAddOnSelections([]);
|
|
2635
|
-
return;
|
|
2636
|
-
}
|
|
2637
|
-
const optionChanged = prevAvailabilityProductOptionIdRef.current !== availabilityProductOptionId;
|
|
2638
|
-
if (optionChanged) {
|
|
2639
|
-
setAddOnSelections([]);
|
|
2640
|
-
prevAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
|
|
2641
|
-
}
|
|
2642
|
-
getAddOns(product.companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
|
|
2643
|
-
.then(setAddOns)
|
|
2644
|
-
.catch(() => setAddOns([]));
|
|
2645
|
-
}, [availabilityProductOptionId, product.companyId]);
|
|
2646
|
-
|
|
2647
572
|
// Auto-select cheapest cancellation policy when pricing config loads (not when change flow already has the booked policy)
|
|
2648
573
|
useEffect(() => {
|
|
2649
574
|
if (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0 && !cancellationPolicyId) {
|
|
@@ -2656,74 +581,6 @@ export function NewBookingFlow({
|
|
|
2656
581
|
}
|
|
2657
582
|
}, [pricingConfig?.cancellationPolicies, cancellationPolicyId, currency]);
|
|
2658
583
|
|
|
2659
|
-
useEffect(() => {
|
|
2660
|
-
if (flowUi?.autoSelectFirstAvailableDate !== true) return;
|
|
2661
|
-
if (isPartialLaunch) return;
|
|
2662
|
-
if (initialValues?.dateTime?.trim()) return;
|
|
2663
|
-
if (selectedDate !== '') return;
|
|
2664
|
-
if (dates.length === 0) return;
|
|
2665
|
-
if (hasAutoSelectedPartnerDateRef.current) return;
|
|
2666
|
-
|
|
2667
|
-
// Match Calendar: a day is "sold out" only when every slot has 0 vacancies.
|
|
2668
|
-
// Always auto-pick the first day with any inventory; only if the whole range is empty and user is admin, fall back to the first day.
|
|
2669
|
-
const firstWithInventory = dates.find((d) =>
|
|
2670
|
-
getPickupCutoffEligibleTimesForDate(d).some((a) => (a.vacancies ?? 0) > 0),
|
|
2671
|
-
);
|
|
2672
|
-
const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
|
|
2673
|
-
if (!first) return;
|
|
2674
|
-
|
|
2675
|
-
hasAutoSelectedPartnerDateRef.current = true;
|
|
2676
|
-
setSelectedDate(first);
|
|
2677
|
-
setSelectedReturnOption(null);
|
|
2678
|
-
setSelectedAvailability(
|
|
2679
|
-
pickDefaultAvailabilityForTimes(getPickupCutoffEligibleTimesForDate(first), activeOptions),
|
|
2680
|
-
);
|
|
2681
|
-
if (!suppressCalendarDateScroll) {
|
|
2682
|
-
setTimeout(() => {
|
|
2683
|
-
const container = contentRef?.current;
|
|
2684
|
-
if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
|
|
2685
|
-
container.scrollBy({ top: 400, behavior: 'smooth' });
|
|
2686
|
-
} else if (typeof window !== 'undefined') {
|
|
2687
|
-
window.scrollBy({ top: 400, behavior: 'smooth' });
|
|
2688
|
-
}
|
|
2689
|
-
}, 100);
|
|
2690
|
-
}
|
|
2691
|
-
}, [
|
|
2692
|
-
flowUi?.autoSelectFirstAvailableDate,
|
|
2693
|
-
isPartialLaunch,
|
|
2694
|
-
initialValues?.dateTime,
|
|
2695
|
-
selectedDate,
|
|
2696
|
-
dates,
|
|
2697
|
-
getPickupCutoffEligibleTimesForDate,
|
|
2698
|
-
isAdmin,
|
|
2699
|
-
useWindowScroll,
|
|
2700
|
-
contentRef,
|
|
2701
|
-
suppressCalendarDateScroll,
|
|
2702
|
-
]);
|
|
2703
|
-
|
|
2704
|
-
useEffect(() => {
|
|
2705
|
-
if (flowUi?.autoSelectFirstHighlightedPickup !== true) return;
|
|
2706
|
-
if (hasAutoSelectedPartnerPickupRef.current) return;
|
|
2707
|
-
if (!highlightedPickupLocationIds?.length) return;
|
|
2708
|
-
if (pickupLocationId) return;
|
|
2709
|
-
if (pickupLocationSkipped) return;
|
|
2710
|
-
if (initialValues?.pickupLocationId?.trim()) return;
|
|
2711
|
-
const locs = product.pickupLocations;
|
|
2712
|
-
if (!locs?.length) return;
|
|
2713
|
-
const match = highlightedPickupLocationIds.find((id) => locs.some((l) => l.id === id));
|
|
2714
|
-
if (!match) return;
|
|
2715
|
-
hasAutoSelectedPartnerPickupRef.current = true;
|
|
2716
|
-
setPickupLocationId(match);
|
|
2717
|
-
setPickupLocationSkipped(false);
|
|
2718
|
-
}, [
|
|
2719
|
-
flowUi?.autoSelectFirstHighlightedPickup,
|
|
2720
|
-
highlightedPickupLocationIds,
|
|
2721
|
-
pickupLocationId,
|
|
2722
|
-
pickupLocationSkipped,
|
|
2723
|
-
initialValues?.pickupLocationId,
|
|
2724
|
-
product.pickupLocations,
|
|
2725
|
-
]);
|
|
2726
|
-
|
|
2727
584
|
const handleTimeSelect = (availability: Availability) => {
|
|
2728
585
|
if (!isPublicSelectableAvailability(availability, new Date())) return;
|
|
2729
586
|
setSelectedAvailability(availability);
|
|
@@ -2737,7 +594,7 @@ export function NewBookingFlow({
|
|
|
2737
594
|
const minQ = 0;
|
|
2738
595
|
const newQty = Math.max(minQ, currentQty + delta);
|
|
2739
596
|
// Admin can overbook; non-admin cannot exceed vacancies
|
|
2740
|
-
if (delta > 0 && !isAdmin &&
|
|
597
|
+
if (delta > 0 && !isAdmin && totalQuantity >= maxAvailable) {
|
|
2741
598
|
return;
|
|
2742
599
|
}
|
|
2743
600
|
setQuantities(prev => ({
|
|
@@ -2747,755 +604,80 @@ export function NewBookingFlow({
|
|
|
2747
604
|
setError('');
|
|
2748
605
|
};
|
|
2749
606
|
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
let cancelled = false;
|
|
2825
|
-
validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, hasOngoingDiscount, selectedAvailability.dateTime).then((result) => {
|
|
2826
|
-
if (cancelled) return;
|
|
2827
|
-
if (!result.valid) {
|
|
2828
|
-
promoAppliedSelectionKeyRef.current = null;
|
|
2829
|
-
setAppliedPromoCode(null);
|
|
2830
|
-
setPromoCodeInput(appliedPromoCode);
|
|
2831
|
-
const errorMsg =
|
|
2832
|
-
result.error === 'Promo codes cannot be stacked with deals'
|
|
2833
|
-
? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
|
|
2834
|
-
: (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
|
|
2835
|
-
setPromoCodeError(errorMsg);
|
|
2836
|
-
setForcedCancellationPolicy(null);
|
|
2837
|
-
setCancellationPolicyId(null);
|
|
2838
|
-
fetchedRangesRef.current = [];
|
|
2839
|
-
}
|
|
2840
|
-
});
|
|
2841
|
-
return () => { cancelled = true; };
|
|
2842
|
-
}, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t, selectedAvailabilityKey, selectedAvailability]);
|
|
2843
|
-
|
|
2844
|
-
// Ref to avoid effect re-running when handleApplyPromo identity changes (t changes every render)
|
|
2845
|
-
const handleApplyPromoRef = useRef(handleApplyPromo);
|
|
2846
|
-
handleApplyPromoRef.current = handleApplyPromo;
|
|
2847
|
-
|
|
2848
|
-
// Auto-apply promo when user stops typing (mobile-friendly, no Enter key needed).
|
|
2849
|
-
// Do not depend on promoCodeValidating: when it flips false after a failed validate, that would
|
|
2850
|
-
// re-run this effect and schedule another timer → repeated /validate for the same bad code.
|
|
2851
|
-
useEffect(() => {
|
|
2852
|
-
const trimmed = promoCodeInput.trim().toUpperCase();
|
|
2853
|
-
if (!trimmed) return;
|
|
2854
|
-
if (!selectedAvailability || totalQuantity <= 0) return;
|
|
2855
|
-
if (appliedPromoCode === trimmed) return;
|
|
2856
|
-
|
|
2857
|
-
const timer = setTimeout(() => {
|
|
2858
|
-
handleApplyPromoRef.current();
|
|
2859
|
-
}, 600);
|
|
2860
|
-
|
|
2861
|
-
return () => clearTimeout(timer);
|
|
2862
|
-
}, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
|
|
2863
|
-
|
|
2864
|
-
const cancelPendingReservation = useCallback(() => {
|
|
2865
|
-
if (paymentSubmitInFlightRef.current) return;
|
|
2866
|
-
const pending = pendingReservationRef.current;
|
|
2867
|
-
if (pending) {
|
|
2868
|
-
pendingReservationRef.current = null;
|
|
2869
|
-
cancelReservation(pending.reservationReference).catch(() => {});
|
|
2870
|
-
}
|
|
2871
|
-
setShowCheckoutModal(false);
|
|
2872
|
-
setCheckoutModalData(null);
|
|
2873
|
-
setCheckoutClientSecret('');
|
|
2874
|
-
}, []);
|
|
2875
|
-
|
|
2876
|
-
const cancelPendingReservationBestEffort = useCallback(() => {
|
|
2877
|
-
if (paymentSubmitInFlightRef.current) return;
|
|
2878
|
-
const pending = pendingReservationRef.current;
|
|
2879
|
-
if (pending) {
|
|
2880
|
-
pendingReservationRef.current = null;
|
|
2881
|
-
cancelReservationBestEffort(pending.reservationReference);
|
|
2882
|
-
}
|
|
2883
|
-
setShowCheckoutModal(false);
|
|
2884
|
-
setCheckoutModalData(null);
|
|
2885
|
-
setCheckoutClientSecret('');
|
|
2886
|
-
}, []);
|
|
2887
|
-
|
|
2888
|
-
// Parent surfaces (dialog close / embedded back) emit this when user abandons the booking flow.
|
|
2889
|
-
useEffect(() => {
|
|
2890
|
-
const handleAbandon = () => {
|
|
2891
|
-
cancelPendingReservation();
|
|
2892
|
-
};
|
|
2893
|
-
window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
|
|
2894
|
-
return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
|
|
2895
|
-
}, [cancelPendingReservation]);
|
|
2896
|
-
|
|
2897
|
-
useEffect(() => {
|
|
2898
|
-
const handlePageHide = () => {
|
|
2899
|
-
cancelPendingReservationBestEffort();
|
|
2900
|
-
};
|
|
2901
|
-
window.addEventListener('pagehide', handlePageHide);
|
|
2902
|
-
return () => window.removeEventListener('pagehide', handlePageHide);
|
|
2903
|
-
}, [cancelPendingReservationBestEffort]);
|
|
2904
|
-
|
|
2905
|
-
const handleCheckout = async () => {
|
|
2906
|
-
if (!selectedAvailability || totalQuantity === 0) {
|
|
2907
|
-
setError(t('booking.selectTimeAndTickets'));
|
|
2908
|
-
return;
|
|
2909
|
-
}
|
|
2910
|
-
if (!isPublicSelectableAvailability(selectedAvailability, new Date())) {
|
|
2911
|
-
setSelectedAvailability(null);
|
|
2912
|
-
setSelectedReturnOption(null);
|
|
2913
|
-
setError('This departure is no longer available online because it starts too soon. Please choose a later time.');
|
|
2914
|
-
return;
|
|
2915
|
-
}
|
|
2916
|
-
if (!email) {
|
|
2917
|
-
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
2918
|
-
return;
|
|
2919
|
-
}
|
|
2920
|
-
|
|
2921
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
2922
|
-
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
2923
|
-
return;
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
if (!firstName?.trim()) {
|
|
2927
|
-
setError(t('booking.enterFirstName') || 'Please enter your first name');
|
|
2928
|
-
return;
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
if (!lastName?.trim()) {
|
|
2932
|
-
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
2933
|
-
return;
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
2937
|
-
if (
|
|
2938
|
-
product.pickupLocations &&
|
|
2939
|
-
product.pickupLocations.length > 0 &&
|
|
2940
|
-
!pickupLocationId &&
|
|
2941
|
-
(!pickupLocationSkipped || hidePickupSkipOption)
|
|
2942
|
-
) {
|
|
2943
|
-
setError(t('booking.selectPickupLocation'));
|
|
2944
|
-
return;
|
|
2945
|
-
}
|
|
2946
|
-
const externalValidationError = validateBeforeCheckout?.({
|
|
2947
|
-
selectedDate: selectedDate || null,
|
|
2948
|
-
selectedAvailability,
|
|
2949
|
-
itineraryItems: computeItineraryDisplay(),
|
|
2950
|
-
});
|
|
2951
|
-
if (externalValidationError) {
|
|
2952
|
-
setError(externalValidationError);
|
|
2953
|
-
return;
|
|
2954
|
-
}
|
|
2955
|
-
|
|
2956
|
-
setLoading(true);
|
|
2957
|
-
setError('');
|
|
2958
|
-
paymentSubmitInFlightRef.current = false;
|
|
2959
|
-
|
|
2960
|
-
try {
|
|
2961
|
-
const bookingItems = Object.entries(quantities)
|
|
2962
|
-
.filter(([, count]) => count > 0)
|
|
2963
|
-
.map(([category, count]) => ({ category, count }));
|
|
2964
|
-
|
|
2965
|
-
// Get the productOptionId from the selected availability (we tagged it when fetching)
|
|
2966
|
-
const availabilityProductOptionId = getAvailabilityOptionId(selectedAvailability);
|
|
2967
|
-
|
|
2968
|
-
if (!availabilityProductOptionId) {
|
|
2969
|
-
setError('No product option selected');
|
|
2970
|
-
setLoading(false);
|
|
2971
|
-
return;
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
2975
|
-
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
2976
|
-
product.productId,
|
|
2977
|
-
availabilityProductOptionId,
|
|
2978
|
-
),
|
|
2979
|
-
forcePartnerPortalChannel: partnerPortalBooking,
|
|
2980
|
-
forceDashboardSource: bookingAppMode === 'provider-dashboard',
|
|
2981
|
-
});
|
|
2982
|
-
|
|
2983
|
-
// Get the hotel name if a pickup location was selected
|
|
2984
|
-
const selectedPickupLocation = pickupLocationId
|
|
2985
|
-
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
2986
|
-
: null;
|
|
2987
|
-
|
|
2988
|
-
try {
|
|
2989
|
-
const refreshed = await refreshSelectedDateDetailsForCheckout();
|
|
2990
|
-
const outbound = findMergedAvailabilityForSelection(refreshed, selectedAvailability);
|
|
2991
|
-
const outboundVacancies = outbound?.vacancies ?? null;
|
|
2992
|
-
const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
|
|
2993
|
-
if (
|
|
2994
|
-
!isAdmin &&
|
|
2995
|
-
((outboundVacancies != null && outboundVacancies < totalQuantity) ||
|
|
2996
|
-
(selectedReturnOption && returnVacancies != null && returnVacancies < totalQuantity))
|
|
2997
|
-
) {
|
|
2998
|
-
setError(
|
|
2999
|
-
describeStandardTourCapacityConflictMessage({
|
|
3000
|
-
partySize: totalQuantity,
|
|
3001
|
-
outboundVacancies,
|
|
3002
|
-
returnVacancies,
|
|
3003
|
-
hasReturnSelection: !!selectedReturnOption,
|
|
3004
|
-
})
|
|
3005
|
-
);
|
|
3006
|
-
setLoading(false);
|
|
3007
|
-
return;
|
|
3008
|
-
}
|
|
3009
|
-
} catch (refreshErr) {
|
|
3010
|
-
console.warn('Fresh availability check failed before reserve; falling back to reserve validation.', refreshErr);
|
|
3011
|
-
}
|
|
3012
|
-
|
|
3013
|
-
const reservation = await createReservation({
|
|
3014
|
-
productId: availabilityProductOptionId, // GetYourGuide passes productOptionId values in productId field
|
|
3015
|
-
dateTime: selectedAvailability.dateTime,
|
|
3016
|
-
availabilityId: selectedAvailability.availabilityId || undefined,
|
|
3017
|
-
bookingItems,
|
|
3018
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
3019
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
|
|
3020
|
-
currency: currency,
|
|
3021
|
-
promoCode: appliedPromoCode || undefined,
|
|
3022
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
3023
|
-
addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
|
|
3024
|
-
...(isAdmin ? { allowOverbook: true } : {}),
|
|
3025
|
-
// Pass hotel name when pickup location is selected (for reference)
|
|
3026
|
-
// Don't set travelerHotel when user selects "I don't know" - leave it undefined
|
|
3027
|
-
// This allows us to distinguish between "unknown" (null) and "unmapped hotel name" (not null)
|
|
3028
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
3029
|
-
// For standard bookings, backend calculates startTime from availability + pickup location offset
|
|
3030
|
-
// When pickup location is skipped, backend will store null for startTime
|
|
3031
|
-
...(bookingSourceContext.sourceMetadata
|
|
3032
|
-
? {
|
|
3033
|
-
source: bookingSourceContext.source,
|
|
3034
|
-
sourceMetadata: bookingSourceContext.sourceMetadata,
|
|
3035
|
-
source_metadata: bookingSourceContext.source_metadata,
|
|
3036
|
-
}
|
|
3037
|
-
: {}),
|
|
3038
|
-
});
|
|
3039
|
-
pendingReservationRef.current = { reservationReference: reservation.reservationReference };
|
|
3040
|
-
|
|
3041
|
-
if (!reservation.reservationReference) {
|
|
3042
|
-
throw new Error('Invalid reservation response: missing reservationReference');
|
|
3043
|
-
}
|
|
3044
|
-
|
|
3045
|
-
// Note: Do NOT call onSuccess here for paid bookings — we're about to show the Stripe
|
|
3046
|
-
// CheckoutModal. onSuccess (e.g. closing the parent dialog) should only run when we're
|
|
3047
|
-
// actually done (free booking redirect, admin confirm-without-payment). Calling it here
|
|
3048
|
-
// would close the dialog before the payment modal opens.
|
|
3049
|
-
|
|
3050
|
-
// Update stored booking data with reservation reference
|
|
3051
|
-
try {
|
|
3052
|
-
const storedBooking = sessionStorage.getItem('pendingBooking');
|
|
3053
|
-
if (storedBooking) {
|
|
3054
|
-
const booking = JSON.parse(storedBooking);
|
|
3055
|
-
if (reservation?.reservationReference) {
|
|
3056
|
-
booking.reservationReference = reservation.reservationReference;
|
|
3057
|
-
}
|
|
3058
|
-
booking.totalPrice = reservation?.totalAmount || totalPrice;
|
|
3059
|
-
booking.currency = reservation?.currency || currency || selectedAvailability.currency || 'CAD';
|
|
3060
|
-
sessionStorage.setItem('pendingBooking', JSON.stringify(booking));
|
|
3061
|
-
}
|
|
3062
|
-
} catch (e) {
|
|
3063
|
-
console.warn('Failed to update booking data', e);
|
|
3064
|
-
}
|
|
3065
|
-
|
|
3066
|
-
// Create Payment Intent for embedded checkout modal (order summary with strikethrough + green, Payment Element)
|
|
3067
|
-
const datePart = selectedAvailability.dateTime.split('T')[0];
|
|
3068
|
-
const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
|
|
3069
|
-
|
|
3070
|
-
// Itinerary for storage: when pickup unknown, pickup step is always a range (e.g. "9 AM - 10:00 AM") so /manage and email show it; drop-off stays TBD
|
|
3071
|
-
const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
|
|
3072
|
-
|
|
3073
|
-
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
3074
|
-
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
3075
|
-
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
3076
|
-
const amountDueForCheckout = displayTotalPrice;
|
|
3077
|
-
const augmentedCheckoutLines =
|
|
3078
|
-
priceSummaryAugmentation?.lines
|
|
3079
|
-
?.slice(checkoutPriceSummaryLines.length)
|
|
3080
|
-
.flatMap((line) =>
|
|
3081
|
-
line.kind === 'line'
|
|
3082
|
-
? [
|
|
3083
|
-
{
|
|
3084
|
-
label: line.label,
|
|
3085
|
-
amount: line.amount,
|
|
3086
|
-
type: line.type ?? 'FEE',
|
|
3087
|
-
quantity: line.quantity ?? undefined,
|
|
3088
|
-
},
|
|
3089
|
-
]
|
|
3090
|
-
: []
|
|
3091
|
-
) ?? [];
|
|
3092
|
-
const lines = [
|
|
3093
|
-
...ticketLineItems.map((line) => ({
|
|
3094
|
-
label: line.category,
|
|
3095
|
-
amount: line.itemTotal,
|
|
3096
|
-
type: 'TICKET' as const,
|
|
3097
|
-
quantity: line.qty,
|
|
3098
|
-
})),
|
|
3099
|
-
...(checkoutReturnLineAmount !== 0
|
|
3100
|
-
? [
|
|
3101
|
-
{
|
|
3102
|
-
label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
3103
|
-
amount: checkoutReturnLineAmount,
|
|
3104
|
-
type: 'RETURN_OPTION' as const,
|
|
3105
|
-
quantity: totalQuantity,
|
|
3106
|
-
},
|
|
3107
|
-
]
|
|
3108
|
-
: []),
|
|
3109
|
-
...(cancellationPolicyFee > 0
|
|
3110
|
-
? [
|
|
3111
|
-
{
|
|
3112
|
-
label: effectiveCancellationPolicyLabel,
|
|
3113
|
-
amount: cancellationPolicyFee,
|
|
3114
|
-
type: 'CANCELLATION_UPGRADE' as const,
|
|
3115
|
-
},
|
|
3116
|
-
]
|
|
3117
|
-
: []),
|
|
3118
|
-
...addOnSelections
|
|
3119
|
-
.map((sel) => {
|
|
3120
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
3121
|
-
if (!addOn) return null;
|
|
3122
|
-
const base = addOn.price ?? 0;
|
|
3123
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
3124
|
-
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
3125
|
-
const qty = sel.quantity ?? 1;
|
|
3126
|
-
const amt = (base + adj) * qty;
|
|
3127
|
-
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
3128
|
-
return {
|
|
3129
|
-
label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name,
|
|
3130
|
-
amount: amt,
|
|
3131
|
-
type: 'FEE' as const,
|
|
3132
|
-
quantity: qty,
|
|
3133
|
-
};
|
|
3134
|
-
})
|
|
3135
|
-
.filter((x): x is NonNullable<typeof x> => x != null),
|
|
3136
|
-
...augmentedCheckoutLines,
|
|
3137
|
-
...feeLineItems.map((fee) => ({
|
|
3138
|
-
label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
3139
|
-
amount: fee.totalAmount,
|
|
3140
|
-
type: 'FEE' as const,
|
|
3141
|
-
quantity: totalQuantity,
|
|
3142
|
-
})),
|
|
3143
|
-
...(!isTaxIncludedInPrice && taxForBreakdown > 0
|
|
3144
|
-
? [
|
|
3145
|
-
{
|
|
3146
|
-
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
3147
|
-
amount: taxForBreakdown,
|
|
3148
|
-
type: 'TAX' as const,
|
|
3149
|
-
},
|
|
3150
|
-
]
|
|
3151
|
-
: []),
|
|
3152
|
-
...(effectivePromoDiscountAmount > 0
|
|
3153
|
-
? [
|
|
3154
|
-
{
|
|
3155
|
-
label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (t('booking.discount') || 'Discount'),
|
|
3156
|
-
amount: -effectivePromoDiscountAmount,
|
|
3157
|
-
type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
|
|
3158
|
-
},
|
|
3159
|
-
]
|
|
3160
|
-
: []),
|
|
3161
|
-
];
|
|
3162
|
-
const checkoutBreakdown = buildCheckoutBreakdown({
|
|
3163
|
-
lines,
|
|
3164
|
-
totalAmount: amountDueForCheckout,
|
|
3165
|
-
currency,
|
|
3166
|
-
roundingLabel: t('booking.rounding') || 'Rounding',
|
|
3167
|
-
});
|
|
3168
|
-
|
|
3169
|
-
if (flowUi?.partnerDeferredInvoice) {
|
|
3170
|
-
if (!reservation?.reservationReference) {
|
|
3171
|
-
throw new Error('Missing reservation reference for partner booking confirmation');
|
|
3172
|
-
}
|
|
3173
|
-
const confirmedBooking = await confirmPartnerBookingWithoutPayment({
|
|
3174
|
-
reservationReference: reservation.reservationReference,
|
|
3175
|
-
productId: product.productId,
|
|
3176
|
-
optionId: availabilityProductOptionId,
|
|
3177
|
-
date: datePart,
|
|
3178
|
-
time: timePart,
|
|
3179
|
-
customerEmail: email || undefined,
|
|
3180
|
-
customerFirstName: firstName.trim() || undefined,
|
|
3181
|
-
customerLastName: lastName.trim() || undefined,
|
|
3182
|
-
...optionalCheckoutContactFields(phoneNumber),
|
|
3183
|
-
currency: currency,
|
|
3184
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
3185
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
3186
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
3187
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
3188
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
3189
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
3190
|
-
checkoutBreakdown,
|
|
3191
|
-
dependentAddOnSelection: dependentAddOnSelection || undefined,
|
|
3192
|
-
depositAmount: 0,
|
|
3193
|
-
balanceAmount: amountDueForCheckout,
|
|
3194
|
-
totalAmount: amountDueForCheckout,
|
|
3195
|
-
...bookingSourceContext,
|
|
3196
|
-
});
|
|
3197
|
-
pendingReservationRef.current = null;
|
|
3198
|
-
const ref = formatBookingRefForDisplay(confirmedBooking.bookingReference);
|
|
3199
|
-
const ln = lastName.trim();
|
|
3200
|
-
onSuccess?.({
|
|
3201
|
-
reservationReference: reservation.reservationReference,
|
|
3202
|
-
bookingReference: confirmedBooking.bookingReference,
|
|
3203
|
-
});
|
|
3204
|
-
if (onShowManage) {
|
|
3205
|
-
onShowManage({ ref, lastName: ln });
|
|
3206
|
-
} else {
|
|
3207
|
-
const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
|
|
3208
|
-
window.location.href = `/manage-booking?${params.toString()}`;
|
|
3209
|
-
}
|
|
3210
|
-
setLoading(false);
|
|
3211
|
-
return;
|
|
3212
|
-
}
|
|
3213
|
-
|
|
3214
|
-
const paymentIntent = await createPaymentIntent({
|
|
3215
|
-
productId: product.productId,
|
|
3216
|
-
optionId: availabilityProductOptionId,
|
|
3217
|
-
date: datePart,
|
|
3218
|
-
time: timePart,
|
|
3219
|
-
quantity: totalQuantity,
|
|
3220
|
-
customerEmail: email,
|
|
3221
|
-
customerFirstName: firstName.trim() || undefined,
|
|
3222
|
-
customerLastName: lastName.trim() || undefined,
|
|
3223
|
-
...optionalCheckoutContactFields(phoneNumber),
|
|
3224
|
-
currency: currency,
|
|
3225
|
-
reservationReference: reservation?.reservationReference,
|
|
3226
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
3227
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
3228
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
3229
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
|
|
3230
|
-
promoCode: appliedPromoCode || undefined,
|
|
3231
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
3232
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
3233
|
-
checkoutBreakdown,
|
|
3234
|
-
dependentAddOnSelection: dependentAddOnSelection || undefined,
|
|
3235
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
3236
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
3237
|
-
...bookingSourceContext,
|
|
3238
|
-
});
|
|
3239
|
-
|
|
3240
|
-
// Free booking (e.g. voucher covers full total): confirm without payment, then redirect to success
|
|
3241
|
-
if ('freeBooking' in paymentIntent && paymentIntent.freeBooking) {
|
|
3242
|
-
if (!reservation?.reservationReference) {
|
|
3243
|
-
throw new Error('Missing reservation reference for free booking confirmation');
|
|
3244
|
-
}
|
|
3245
|
-
|
|
3246
|
-
const freeBookingResult = await confirmFreeBooking({
|
|
3247
|
-
reservationReference: reservation?.reservationReference ?? '',
|
|
3248
|
-
productId: product.productId,
|
|
3249
|
-
optionId: availabilityProductOptionId,
|
|
3250
|
-
date: datePart,
|
|
3251
|
-
time: timePart,
|
|
3252
|
-
customerEmail: email || undefined,
|
|
3253
|
-
customerFirstName: firstName.trim() || undefined,
|
|
3254
|
-
customerLastName: lastName.trim() || undefined,
|
|
3255
|
-
...optionalCheckoutContactFields(phoneNumber),
|
|
3256
|
-
currency: currency,
|
|
3257
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
3258
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
3259
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
3260
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
3261
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
3262
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
3263
|
-
dependentAddOnSelection: dependentAddOnSelection || undefined,
|
|
3264
|
-
...bookingSourceContext,
|
|
3265
|
-
});
|
|
3266
|
-
pendingReservationRef.current = null;
|
|
3267
|
-
|
|
3268
|
-
// Show manage UI: in provider-dashboard use callback (e.g. dialog); otherwise redirect to /manage
|
|
3269
|
-
const ref = formatBookingRefForDisplay(freeBookingResult.bookingReference);
|
|
3270
|
-
const ln = lastName.trim();
|
|
3271
|
-
onSuccess?.({ reservationReference: reservation?.reservationReference ?? '' });
|
|
3272
|
-
if (onShowManage) {
|
|
3273
|
-
onShowManage({ ref, lastName: ln });
|
|
3274
|
-
} else {
|
|
3275
|
-
const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
|
|
3276
|
-
window.location.href = `/manage-booking?${params.toString()}`;
|
|
3277
|
-
}
|
|
3278
|
-
setLoading(false);
|
|
3279
|
-
return;
|
|
3280
|
-
}
|
|
3281
|
-
|
|
3282
|
-
// Admin: show choice to pay now or confirm without payment (customer owes full balance)
|
|
3283
|
-
if (isAdmin) {
|
|
3284
|
-
if (!reservation?.reservationReference) {
|
|
3285
|
-
throw new Error('Missing reservation reference for admin payment flow');
|
|
3286
|
-
}
|
|
3287
|
-
setError('');
|
|
3288
|
-
setAdminChoiceData({
|
|
3289
|
-
reservationReference: reservation.reservationReference,
|
|
3290
|
-
reservationExpiration: reservation.expiresAt,
|
|
3291
|
-
checkoutBreakdown,
|
|
3292
|
-
totalAmount: amountDueForCheckout,
|
|
3293
|
-
datePart,
|
|
3294
|
-
timePart,
|
|
3295
|
-
availabilityProductOptionId,
|
|
3296
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
3297
|
-
clientSecret: paymentIntent.clientSecret ?? '',
|
|
3298
|
-
ticketLinesForModal: ticketLineItems.map((line) => {
|
|
3299
|
-
const rate = pricing.find((r) => r.category === line.category);
|
|
3300
|
-
const breakdown = getPriceBreakdown(
|
|
3301
|
-
line.category,
|
|
3302
|
-
rate?.priceCAD ?? 0,
|
|
3303
|
-
rate?.baseInDisplayCurrency,
|
|
3304
|
-
rate?.appliedAdjustments ?? []
|
|
3305
|
-
);
|
|
3306
|
-
return { line, breakdown };
|
|
3307
|
-
}),
|
|
3308
|
-
feeLineItems: augmentedModalFeeLineItems,
|
|
3309
|
-
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
3310
|
-
cancellationPolicyFee,
|
|
3311
|
-
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
3312
|
-
subtotal: displaySubtotal,
|
|
3313
|
-
tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
|
|
3314
|
-
totalQuantity,
|
|
3315
|
-
isTaxIncludedInPrice,
|
|
3316
|
-
taxRate: pricingConfig?.taxRate ?? 0,
|
|
3317
|
-
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
3318
|
-
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
|
|
3319
|
-
});
|
|
3320
|
-
setShowAdminPaymentChoice(true);
|
|
3321
|
-
setLoading(false);
|
|
3322
|
-
return;
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
|
-
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItems.map((line) => {
|
|
3326
|
-
const rate = pricing.find((r) => r.category === line.category);
|
|
3327
|
-
const breakdown = getPriceBreakdown(
|
|
3328
|
-
line.category,
|
|
3329
|
-
rate?.priceCAD ?? 0,
|
|
3330
|
-
rate?.baseInDisplayCurrency,
|
|
3331
|
-
rate?.appliedAdjustments ?? []
|
|
3332
|
-
);
|
|
3333
|
-
return { line, breakdown };
|
|
3334
|
-
});
|
|
3335
|
-
|
|
3336
|
-
setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
|
|
3337
|
-
setCheckoutModalData({
|
|
3338
|
-
reservationReference: reservation?.reservationReference ?? '',
|
|
3339
|
-
reservationExpiration: reservation?.expiresAt,
|
|
3340
|
-
customerLastName: lastName.trim(),
|
|
3341
|
-
bookingDate: datePart,
|
|
3342
|
-
successUrlOverride: undefined,
|
|
3343
|
-
ticketLines: ticketLinesForModal,
|
|
3344
|
-
feeLineItems: augmentedModalFeeLineItems,
|
|
3345
|
-
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
3346
|
-
cancellationPolicyFee,
|
|
3347
|
-
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
3348
|
-
subtotal: displaySubtotal,
|
|
3349
|
-
tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
|
|
3350
|
-
total: amountDueForCheckout,
|
|
3351
|
-
totalQuantity,
|
|
3352
|
-
isTaxIncludedInPrice,
|
|
3353
|
-
taxRate: pricingConfig?.taxRate ?? 0,
|
|
3354
|
-
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
3355
|
-
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
|
|
3356
|
-
changeTotals: undefined,
|
|
3357
|
-
});
|
|
3358
|
-
setShowCheckoutModal(true);
|
|
3359
|
-
setLoading(false);
|
|
3360
|
-
} catch (err) {
|
|
3361
|
-
if (isInsufficientCapacityReserveError(err)) {
|
|
3362
|
-
try {
|
|
3363
|
-
const merged = await reloadAvailabilitiesAfterReserveConflict();
|
|
3364
|
-
const outbound = findMergedAvailabilityForSelection(merged, selectedAvailability);
|
|
3365
|
-
const outboundVacancies = outbound?.vacancies ?? null;
|
|
3366
|
-
const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
|
|
3367
|
-
setError(
|
|
3368
|
-
describeStandardTourCapacityConflictMessage({
|
|
3369
|
-
partySize: totalQuantity,
|
|
3370
|
-
outboundVacancies,
|
|
3371
|
-
returnVacancies,
|
|
3372
|
-
hasReturnSelection: !!selectedReturnOption,
|
|
3373
|
-
})
|
|
3374
|
-
);
|
|
3375
|
-
reportReserveCapacityConflictClientContext({
|
|
3376
|
-
flow: 'standard_tour',
|
|
3377
|
-
productId: product.productId,
|
|
3378
|
-
selectedDate: selectedDate || null,
|
|
3379
|
-
outboundDateTime: selectedAvailability?.dateTime ?? null,
|
|
3380
|
-
outboundVacanciesAfterRefresh: outboundVacancies,
|
|
3381
|
-
returnVacanciesAfterRefresh: returnVacancies,
|
|
3382
|
-
partySizeOrPassengers: totalQuantity,
|
|
3383
|
-
hasReturnSelection: !!selectedReturnOption,
|
|
3384
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3385
|
-
reloadAvailabilitiesSucceeded: true,
|
|
3386
|
-
});
|
|
3387
|
-
} catch (reloadErr) {
|
|
3388
|
-
setError(
|
|
3389
|
-
describeStandardTourCapacityConflictMessage({
|
|
3390
|
-
partySize: totalQuantity,
|
|
3391
|
-
outboundVacancies: null,
|
|
3392
|
-
returnVacancies: null,
|
|
3393
|
-
hasReturnSelection: !!selectedReturnOption,
|
|
3394
|
-
})
|
|
3395
|
-
);
|
|
3396
|
-
reportReserveCapacityConflictClientContext({
|
|
3397
|
-
flow: 'standard_tour',
|
|
3398
|
-
productId: product.productId,
|
|
3399
|
-
selectedDate: selectedDate || null,
|
|
3400
|
-
outboundDateTime: selectedAvailability?.dateTime ?? null,
|
|
3401
|
-
outboundVacanciesAfterRefresh: null,
|
|
3402
|
-
returnVacanciesAfterRefresh: null,
|
|
3403
|
-
partySizeOrPassengers: totalQuantity,
|
|
3404
|
-
hasReturnSelection: !!selectedReturnOption,
|
|
3405
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3406
|
-
reloadAvailabilitiesSucceeded: false,
|
|
3407
|
-
reloadErrorMessage:
|
|
3408
|
-
reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
|
|
3409
|
-
});
|
|
3410
|
-
}
|
|
3411
|
-
setLoading(false);
|
|
3412
|
-
return;
|
|
3413
|
-
}
|
|
3414
|
-
setError(err instanceof Error ? err.message : 'Something went wrong');
|
|
3415
|
-
setLoading(false);
|
|
3416
|
-
}
|
|
3417
|
-
};
|
|
3418
|
-
|
|
3419
|
-
const handleConfirmWithoutPayment = async () => {
|
|
3420
|
-
if (!adminChoiceData) return;
|
|
3421
|
-
setLoading(true);
|
|
3422
|
-
setError('');
|
|
3423
|
-
try {
|
|
3424
|
-
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
3425
|
-
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
3426
|
-
product.productId,
|
|
3427
|
-
adminChoiceData.availabilityProductOptionId,
|
|
3428
|
-
),
|
|
3429
|
-
forcePartnerPortalChannel: partnerPortalBooking,
|
|
3430
|
-
forceDashboardSource: bookingAppMode === 'provider-dashboard',
|
|
3431
|
-
});
|
|
3432
|
-
const result = await confirmBookingWithoutPayment({
|
|
3433
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
3434
|
-
productId: product.productId,
|
|
3435
|
-
optionId: adminChoiceData.availabilityProductOptionId,
|
|
3436
|
-
date: adminChoiceData.datePart,
|
|
3437
|
-
time: adminChoiceData.timePart,
|
|
3438
|
-
customerEmail: email || undefined,
|
|
3439
|
-
customerFirstName: firstName.trim() || undefined,
|
|
3440
|
-
customerLastName: lastName.trim() || undefined,
|
|
3441
|
-
...optionalCheckoutContactFields(phoneNumber),
|
|
3442
|
-
currency: currency,
|
|
3443
|
-
travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
|
|
3444
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
3445
|
-
itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
|
|
3446
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
3447
|
-
skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
|
|
3448
|
-
disableAutoCommunications: disableAutoCommunications ? true : undefined,
|
|
3449
|
-
checkoutBreakdown: adminChoiceData.checkoutBreakdown,
|
|
3450
|
-
depositAmount: 0,
|
|
3451
|
-
balanceAmount: adminChoiceData.totalAmount,
|
|
3452
|
-
totalAmount: adminChoiceData.totalAmount,
|
|
3453
|
-
...bookingSourceContext,
|
|
3454
|
-
});
|
|
3455
|
-
pendingReservationRef.current = null;
|
|
3456
|
-
const ref = formatBookingRefForDisplay(result.bookingReference);
|
|
3457
|
-
const ln = lastName.trim();
|
|
3458
|
-
setShowAdminPaymentChoice(false);
|
|
3459
|
-
setAdminChoiceData(null);
|
|
3460
|
-
onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
|
|
3461
|
-
if (onShowManage) {
|
|
3462
|
-
onShowManage({ ref, lastName: ln });
|
|
3463
|
-
} else {
|
|
3464
|
-
const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
|
|
3465
|
-
window.location.href = `/manage-booking?${params.toString()}`;
|
|
3466
|
-
}
|
|
3467
|
-
} catch (err) {
|
|
3468
|
-
setError(err instanceof Error ? err.message : 'Failed to confirm booking');
|
|
3469
|
-
} finally {
|
|
3470
|
-
setLoading(false);
|
|
3471
|
-
}
|
|
3472
|
-
};
|
|
3473
|
-
|
|
3474
|
-
const handlePayNow = () => {
|
|
3475
|
-
if (!adminChoiceData) return;
|
|
3476
|
-
setShowAdminPaymentChoice(false);
|
|
3477
|
-
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
3478
|
-
setCheckoutModalData({
|
|
3479
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
3480
|
-
reservationExpiration: adminChoiceData.reservationExpiration,
|
|
3481
|
-
customerLastName: lastName.trim(),
|
|
3482
|
-
ticketLines: adminChoiceData.ticketLinesForModal,
|
|
3483
|
-
feeLineItems: adminChoiceData.feeLineItems,
|
|
3484
|
-
returnPriceAdjustment: adminChoiceData.returnPriceAdjustment,
|
|
3485
|
-
cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
|
|
3486
|
-
cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
|
|
3487
|
-
subtotal: adminChoiceData.subtotal,
|
|
3488
|
-
tax: adminChoiceData.tax,
|
|
3489
|
-
total: adminChoiceData.totalAmount,
|
|
3490
|
-
totalQuantity: adminChoiceData.totalQuantity,
|
|
3491
|
-
isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
|
|
3492
|
-
taxRate: adminChoiceData.taxRate,
|
|
3493
|
-
promoDiscountAmount: adminChoiceData.promoDiscountAmount,
|
|
3494
|
-
discountLabel: adminChoiceData.discountLabel,
|
|
3495
|
-
});
|
|
3496
|
-
setShowCheckoutModal(true);
|
|
3497
|
-
setAdminChoiceData(null);
|
|
3498
|
-
};
|
|
607
|
+
const {
|
|
608
|
+
showCheckoutModal,
|
|
609
|
+
checkoutClientSecret,
|
|
610
|
+
checkoutModalData,
|
|
611
|
+
showAdminPaymentChoice,
|
|
612
|
+
adminChoiceData,
|
|
613
|
+
cancelPendingReservation,
|
|
614
|
+
handleCheckout,
|
|
615
|
+
handleConfirmWithoutPayment,
|
|
616
|
+
handlePayNow,
|
|
617
|
+
handlePaymentSubmitStart,
|
|
618
|
+
handlePaymentSubmitError,
|
|
619
|
+
handleAdminPaymentChoiceCancel,
|
|
620
|
+
} = useStandardBookingCheckoutController({
|
|
621
|
+
selectedAvailability,
|
|
622
|
+
setSelectedAvailability,
|
|
623
|
+
selectedReturnOption,
|
|
624
|
+
setSelectedReturnOption,
|
|
625
|
+
isPublicSelectableAvailability,
|
|
626
|
+
totalQuantity,
|
|
627
|
+
t,
|
|
628
|
+
email,
|
|
629
|
+
firstName,
|
|
630
|
+
lastName,
|
|
631
|
+
phoneNumber,
|
|
632
|
+
product,
|
|
633
|
+
pickupLocationId,
|
|
634
|
+
pickupLocationSkipped,
|
|
635
|
+
hidePickupSkipOption,
|
|
636
|
+
validateBeforeCheckout,
|
|
637
|
+
selectedDate,
|
|
638
|
+
computeItineraryDisplay,
|
|
639
|
+
computeItineraryDisplayForStorage,
|
|
640
|
+
quantities,
|
|
641
|
+
bookingSourceAttribution,
|
|
642
|
+
partnerPortalBooking,
|
|
643
|
+
forceDashboardSource: bookingAppMode === 'provider-dashboard',
|
|
644
|
+
isAdmin,
|
|
645
|
+
pricingConfig,
|
|
646
|
+
refreshSelectedDateDetailsForCheckout,
|
|
647
|
+
displayTotalPrice,
|
|
648
|
+
displaySubtotal,
|
|
649
|
+
totalPrice,
|
|
650
|
+
currency,
|
|
651
|
+
appliedPromoCode,
|
|
652
|
+
cancellationPolicyId,
|
|
653
|
+
addOnSelections,
|
|
654
|
+
dependentAddOnSelection,
|
|
655
|
+
ticketLineItems,
|
|
656
|
+
pricing,
|
|
657
|
+
getPriceBreakdown,
|
|
658
|
+
checkoutReturnLineAmount,
|
|
659
|
+
cancellationPolicyFee,
|
|
660
|
+
effectiveCancellationPolicyLabel,
|
|
661
|
+
addOns,
|
|
662
|
+
feeLineItems,
|
|
663
|
+
feeLineItemsWithAddOns,
|
|
664
|
+
priceSummaryAugmentation,
|
|
665
|
+
checkoutPriceSummaryLines,
|
|
666
|
+
isTaxIncludedInPrice,
|
|
667
|
+
tax,
|
|
668
|
+
effectiveTax,
|
|
669
|
+
effectivePromoDiscountAmount,
|
|
670
|
+
isGiftCard,
|
|
671
|
+
partnerDeferredInvoice: Boolean(flowUi?.partnerDeferredInvoice),
|
|
672
|
+
termsAcceptedAt,
|
|
673
|
+
skipConfirmationCommunications,
|
|
674
|
+
disableAutoCommunications,
|
|
675
|
+
reloadAvailabilitiesAfterReserveConflict,
|
|
676
|
+
setLoading,
|
|
677
|
+
setError,
|
|
678
|
+
onSuccess,
|
|
679
|
+
onShowManage,
|
|
680
|
+
});
|
|
3499
681
|
|
|
3500
682
|
if (activeOptions.length === 0) {
|
|
3501
683
|
return (
|
|
@@ -3507,92 +689,34 @@ export function NewBookingFlow({
|
|
|
3507
689
|
|
|
3508
690
|
return (
|
|
3509
691
|
<div className="booking-flow-root space-y-8">
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
692
|
+
<StandardBookingCheckoutDialogs
|
|
693
|
+
showAdminPaymentChoice={showAdminPaymentChoice}
|
|
694
|
+
adminChoiceData={adminChoiceData}
|
|
695
|
+
checkoutModalData={checkoutModalData}
|
|
696
|
+
showCheckoutModal={showCheckoutModal}
|
|
697
|
+
checkoutClientSecret={checkoutClientSecret}
|
|
3514
698
|
currency={currency}
|
|
699
|
+
locale={locale}
|
|
3515
700
|
loading={loading}
|
|
3516
701
|
error={error}
|
|
3517
702
|
onPayNow={handlePayNow}
|
|
3518
703
|
onConfirmWithoutPayment={handleConfirmWithoutPayment}
|
|
3519
|
-
|
|
704
|
+
onAdminPaymentChoiceCancel={handleAdminPaymentChoiceCancel}
|
|
705
|
+
onCheckoutClose={cancelPendingReservation}
|
|
706
|
+
onPaymentSubmitStart={handlePaymentSubmitStart}
|
|
707
|
+
onPaymentSubmitError={handlePaymentSubmitError}
|
|
708
|
+
getSuccessUrl={getSuccessUrl}
|
|
709
|
+
t={t}
|
|
3520
710
|
/>
|
|
3521
|
-
{checkoutModalData && (
|
|
3522
|
-
<CheckoutModal
|
|
3523
|
-
open={showCheckoutModal}
|
|
3524
|
-
onClose={cancelPendingReservation}
|
|
3525
|
-
onPaymentSubmitStart={() => {
|
|
3526
|
-
paymentSubmitInFlightRef.current = true;
|
|
3527
|
-
}}
|
|
3528
|
-
onPaymentSubmitError={() => {
|
|
3529
|
-
paymentSubmitInFlightRef.current = false;
|
|
3530
|
-
}}
|
|
3531
|
-
clientSecret={checkoutClientSecret}
|
|
3532
|
-
reservationReference={checkoutModalData.reservationReference}
|
|
3533
|
-
reservationExpiration={checkoutModalData.reservationExpiration}
|
|
3534
|
-
customerLastName={checkoutModalData.customerLastName}
|
|
3535
|
-
successUrlOverride={
|
|
3536
|
-
checkoutModalData.successUrlOverride ??
|
|
3537
|
-
(getSuccessUrl
|
|
3538
|
-
? getSuccessUrl({
|
|
3539
|
-
reservationRef: checkoutModalData.reservationReference,
|
|
3540
|
-
lastName: checkoutModalData.customerLastName ?? '',
|
|
3541
|
-
focusDate: checkoutModalData.bookingDate,
|
|
3542
|
-
})
|
|
3543
|
-
: undefined)
|
|
3544
|
-
}
|
|
3545
|
-
ticketLines={checkoutModalData.ticketLines}
|
|
3546
|
-
feeLineItems={checkoutModalData.feeLineItems}
|
|
3547
|
-
returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
|
|
3548
|
-
cancellationPolicyFee={checkoutModalData.cancellationPolicyFee}
|
|
3549
|
-
cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
|
|
3550
|
-
subtotal={checkoutModalData.subtotal}
|
|
3551
|
-
tax={checkoutModalData.tax}
|
|
3552
|
-
total={checkoutModalData.total}
|
|
3553
|
-
promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
|
|
3554
|
-
discountLabel={checkoutModalData.discountLabel}
|
|
3555
|
-
totalQuantity={checkoutModalData.totalQuantity}
|
|
3556
|
-
isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
|
|
3557
|
-
taxRate={checkoutModalData.taxRate}
|
|
3558
|
-
changeTotals={checkoutModalData.changeTotals}
|
|
3559
|
-
currency={currency}
|
|
3560
|
-
locale={locale}
|
|
3561
|
-
t={t}
|
|
3562
|
-
/>
|
|
3563
|
-
)}
|
|
3564
|
-
{/* Image/video collage - vertical video on left, image grid on right */}
|
|
3565
|
-
{productId && flowUi?.showCollage !== false && (() => {
|
|
3566
|
-
const config = catalog.getProductByIdOrSlug(productId) as {
|
|
3567
|
-
display?: {
|
|
3568
|
-
collageImageIds?: string[];
|
|
3569
|
-
imageIds?: string[];
|
|
3570
|
-
};
|
|
3571
|
-
} | null;
|
|
3572
|
-
const displayProducts = catalog.getProducts(defaultStrings) as Record<
|
|
3573
|
-
string,
|
|
3574
|
-
{ id: string; videoUrl?: import('../../constants/products').VideoSources }
|
|
3575
|
-
>;
|
|
3576
|
-
const displayProduct = Object.values(displayProducts).find((p) => p.id === productId);
|
|
3577
|
-
const collageImageIds = config?.display?.collageImageIds ?? config?.display?.imageIds ?? [];
|
|
3578
|
-
const hasVideo = !!displayProduct?.videoUrl;
|
|
3579
|
-
const hasImages = collageImageIds.length > 0;
|
|
3580
|
-
if (!hasVideo && !hasImages) return null;
|
|
3581
|
-
return (
|
|
3582
|
-
<div className="booking-collage-wrapper">
|
|
3583
|
-
<BookingFlowCollage
|
|
3584
|
-
video={displayProduct?.videoUrl}
|
|
3585
|
-
videoPosterImageId={config?.display?.imageIds?.[0]}
|
|
3586
|
-
imageIds={hasImages ? collageImageIds : [config?.display?.imageIds?.[0]].filter(Boolean) as string[]}
|
|
3587
|
-
altPrefix={product.name}
|
|
3588
|
-
/>
|
|
3589
|
-
</div>
|
|
3590
|
-
);
|
|
3591
|
-
})()}
|
|
3592
711
|
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
712
|
+
<StandardBookingMediaIntro
|
|
713
|
+
productId={productId}
|
|
714
|
+
productName={product.name}
|
|
715
|
+
locale={locale}
|
|
716
|
+
showCollage={flowUi?.showCollage !== false}
|
|
717
|
+
showTourDescription={flowUi?.showTourDescription !== false}
|
|
718
|
+
tourDescriptionDefaultExpanded={isPartialLaunch}
|
|
719
|
+
/>
|
|
3596
720
|
|
|
3597
721
|
{isPartialLaunch ? null : (
|
|
3598
722
|
<div className="booking-calendar-section">
|
|
@@ -3665,289 +789,145 @@ export function NewBookingFlow({
|
|
|
3665
789
|
|
|
3666
790
|
{/* Form sections - equal spacing between each */}
|
|
3667
791
|
<div className="mt-6 space-y-6">
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
}
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
formattedDate={formattedDate}
|
|
3696
|
-
itineraryItems={itineraryItems}
|
|
3697
|
-
isBookingComplete={isBookingComplete}
|
|
3698
|
-
isItinerarySticky={isItinerarySticky}
|
|
3699
|
-
stickyTopPx={flowUi?.itineraryStickyTopOffsetPx}
|
|
3700
|
-
isMobile={isMobile}
|
|
3701
|
-
useWindowScroll={useWindowScroll}
|
|
3702
|
-
showTooltip={showTooltip}
|
|
3703
|
-
selectedPickupLocation={selectedPickupLocation}
|
|
3704
|
-
pickupLocationSkipped={pickupLocationSkipped}
|
|
3705
|
-
pickupLocationsCount={product.pickupLocations?.length ?? 0}
|
|
3706
|
-
itineraryRef={itineraryRef}
|
|
3707
|
-
t={t}
|
|
3708
|
-
onTooltipToggle={() => setShowTooltip(!showTooltip)}
|
|
3709
|
-
onTooltipShow={setShowTooltip}
|
|
3710
|
-
/>
|
|
3711
|
-
);
|
|
3712
|
-
})()}
|
|
3713
|
-
|
|
3714
|
-
{/* Select pickup time */}
|
|
3715
|
-
{selectedDate && (
|
|
3716
|
-
<PickupTimeSelector
|
|
3717
|
-
pickupTimes={pickupTimes}
|
|
3718
|
-
selectedDateTime={selectedAvailability?.dateTime ?? null}
|
|
3719
|
-
selectedTicketCount={totalQuantity}
|
|
3720
|
-
optionsMap={optionsMap}
|
|
3721
|
-
hasAnyMostPopular={hasAnyMostPopular}
|
|
3722
|
-
isAdmin={isAdmin}
|
|
3723
|
-
pickupLocationSkipped={pickupLocationSkipped}
|
|
3724
|
-
t={t}
|
|
3725
|
-
onTimeSelect={handleTimeSelect}
|
|
3726
|
-
/>
|
|
3727
|
-
)}
|
|
792
|
+
<StandardBookingItineraryPanel
|
|
793
|
+
selectedDate={selectedDate}
|
|
794
|
+
hideItineraryBox={hideItineraryBox}
|
|
795
|
+
activeOptions={activeOptions}
|
|
796
|
+
destinationsCount={product.destinations?.length ?? 0}
|
|
797
|
+
selectedAvailability={selectedAvailability}
|
|
798
|
+
selectedReturnOption={selectedReturnOption}
|
|
799
|
+
pickupLocationId={pickupLocationId}
|
|
800
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
801
|
+
pickupLocationsCount={product.pickupLocations?.length ?? 0}
|
|
802
|
+
quantities={quantities}
|
|
803
|
+
email={email}
|
|
804
|
+
firstName={firstName}
|
|
805
|
+
lastName={lastName}
|
|
806
|
+
getBaseItineraryItems={computeItineraryDisplay}
|
|
807
|
+
augmentItineraryItems={augmentItineraryItems}
|
|
808
|
+
isItinerarySticky={isItinerarySticky}
|
|
809
|
+
stickyTopPx={flowUi?.itineraryStickyTopOffsetPx}
|
|
810
|
+
isMobile={isMobile}
|
|
811
|
+
useWindowScroll={useWindowScroll}
|
|
812
|
+
showTooltip={showTooltip}
|
|
813
|
+
selectedPickupLocation={selectedPickupLocation}
|
|
814
|
+
itineraryRef={itineraryRef}
|
|
815
|
+
t={t}
|
|
816
|
+
onTooltipToggle={toggleTooltip}
|
|
817
|
+
onTooltipShow={setShowTooltip}
|
|
818
|
+
/>
|
|
3728
819
|
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
820
|
+
<StandardBookingSelectionControlsPanel
|
|
821
|
+
selectedBookingOptionsHydrating={selectedBookingOptionsHydrating}
|
|
822
|
+
selectedDate={selectedDate}
|
|
823
|
+
selectedAvailabilityMatchesDate={selectedAvailabilityMatchesDate}
|
|
824
|
+
selectedAvailability={selectedAvailability}
|
|
825
|
+
selectedReturnOption={selectedReturnOption}
|
|
826
|
+
pickupTimes={pickupTimes}
|
|
827
|
+
optionsMap={optionsMap}
|
|
828
|
+
hasAnyMostPopular={hasAnyMostPopular}
|
|
829
|
+
isAdmin={isAdmin}
|
|
830
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
831
|
+
t={t}
|
|
832
|
+
onTimeSelect={handleTimeSelect}
|
|
833
|
+
returnOptions={returnOptionsWithFloor}
|
|
834
|
+
selectedReturnOptionForDisplay={selectedReturnOptionWithFloor}
|
|
835
|
+
onSelectedReturnOptionChange={setSelectedReturnOption}
|
|
836
|
+
getStaySummary={calculateStaySummary}
|
|
837
|
+
companyTimezone={companyTimezone}
|
|
838
|
+
currency={currency}
|
|
839
|
+
locale={locale}
|
|
840
|
+
afterItinerary={afterItinerary}
|
|
841
|
+
getItineraryItems={computeItineraryDisplay}
|
|
842
|
+
beforeCancellationPolicy={beforeCancellationPolicy}
|
|
843
|
+
cancellationPolicies={pricingConfig?.cancellationPolicies}
|
|
844
|
+
forcedCancellationPolicy={forcedCancellationPolicy}
|
|
845
|
+
cancellationPolicyId={cancellationPolicyId}
|
|
846
|
+
cancellationPolicyRef={cancellationPolicyRef}
|
|
847
|
+
cancellationPolicySectionLabel={flowUi?.cancellationPolicySectionLabel}
|
|
848
|
+
onCancellationPolicySelect={setCancellationPolicyId}
|
|
849
|
+
pricing={pricing}
|
|
850
|
+
quantities={quantities}
|
|
851
|
+
totalQuantity={totalQuantity}
|
|
852
|
+
selectedVacancies={effectivePartySizeCap}
|
|
853
|
+
pickupVacancies={effectiveSelectedPickupVacancies}
|
|
854
|
+
returnVacancies={effectiveSelectedReturnVacancies}
|
|
855
|
+
isSimplifiedPricingView={isSimplifiedPricingView}
|
|
856
|
+
onQuantityChange={handleQuantityChange}
|
|
857
|
+
addOns={addOns}
|
|
858
|
+
addOnSelections={addOnSelections}
|
|
859
|
+
onAddOnSelectionsChange={updateAddOnSelections}
|
|
860
|
+
/>
|
|
3736
861
|
|
|
3737
|
-
{
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
selectedReturnOption={selectedReturnOptionWithFloor}
|
|
3746
|
-
selectedTicketCount={totalQuantity}
|
|
3747
|
-
companyTimezone={companyTimezone}
|
|
862
|
+
{/* Total and Checkout — shared PriceSummary component */}
|
|
863
|
+
{!selectedBookingOptionsHydrating && selectedAvailability && (
|
|
864
|
+
<StandardBookingCheckoutSection
|
|
865
|
+
priceSummaryLines={displayCheckoutPriceSummaryLines}
|
|
866
|
+
totalPrice={displayTotalPrice}
|
|
867
|
+
subtotal={checkoutFormSubtotal}
|
|
868
|
+
taxAmount={checkoutFormTaxAmount}
|
|
869
|
+
taxRate={pricingConfig?.taxRate}
|
|
3748
870
|
currency={currency}
|
|
3749
871
|
locale={locale}
|
|
3750
|
-
isAdmin={isAdmin}
|
|
3751
872
|
t={t}
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
873
|
+
promoCodeInput={promoCodeInput}
|
|
874
|
+
appliedPromoCode={appliedPromoCode}
|
|
875
|
+
promoCodeError={promoCodeError}
|
|
876
|
+
promoCodeValidating={promoCodeValidating}
|
|
877
|
+
promoDiscountAmount={promoDiscountAmount}
|
|
878
|
+
onPromoInputChange={handlePromoInputChange}
|
|
879
|
+
onPromoApply={handleApplyPromo}
|
|
880
|
+
onPromoRemove={handleRemovePromo}
|
|
881
|
+
firstName={firstName}
|
|
882
|
+
lastName={lastName}
|
|
883
|
+
email={email}
|
|
884
|
+
phoneNumber={phoneNumber}
|
|
885
|
+
onFirstNameChange={(value) => {
|
|
886
|
+
setFirstName(value);
|
|
887
|
+
setError('');
|
|
3757
888
|
}}
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
return (
|
|
3784
|
-
<div ref={cancellationPolicyRef}>
|
|
3785
|
-
{beforeCancellationPolicy}
|
|
3786
|
-
<CancellationPolicySelector
|
|
3787
|
-
policies={sortedPolicies}
|
|
3788
|
-
selectedPolicyId={cancellationPolicyId}
|
|
3789
|
-
currency={currency}
|
|
3790
|
-
locale={locale}
|
|
3791
|
-
t={t}
|
|
3792
|
-
onPolicySelect={setCancellationPolicyId}
|
|
3793
|
-
forcedPolicy={forcedCancellationPolicy}
|
|
3794
|
-
sectionLabel={flowUi?.cancellationPolicySectionLabel}
|
|
3795
|
-
/>
|
|
3796
|
-
</div>
|
|
3797
|
-
);
|
|
3798
|
-
})()}
|
|
3799
|
-
|
|
3800
|
-
{/* Ticket Selection */}
|
|
3801
|
-
{!selectedBookingOptionsHydrating && selectedAvailability && (
|
|
3802
|
-
<TicketSelector
|
|
3803
|
-
pricing={pricing}
|
|
3804
|
-
quantities={quantities}
|
|
3805
|
-
totalQuantity={totalQuantity}
|
|
3806
|
-
selectedVacancies={effectivePartySizeCap}
|
|
3807
|
-
companyTimezone={companyTimezone}
|
|
3808
|
-
pickupDateTime={selectedAvailability.dateTime}
|
|
3809
|
-
pickupVacancies={effectiveSelectedPickupVacancies}
|
|
3810
|
-
returnDateTime={selectedReturnOption?.dateTime ?? null}
|
|
3811
|
-
returnVacancies={effectiveSelectedReturnVacancies}
|
|
3812
|
-
resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
|
|
3813
|
-
currency={currency}
|
|
3814
|
-
locale={locale}
|
|
889
|
+
onLastNameChange={(value) => {
|
|
890
|
+
setLastName(value);
|
|
891
|
+
setError('');
|
|
892
|
+
}}
|
|
893
|
+
onEmailChange={(value) => {
|
|
894
|
+
setEmail(value);
|
|
895
|
+
setError('');
|
|
896
|
+
}}
|
|
897
|
+
onPhoneNumberChange={(value) => {
|
|
898
|
+
setPhoneNumber(value);
|
|
899
|
+
setError('');
|
|
900
|
+
}}
|
|
901
|
+
selectedDate={selectedDate}
|
|
902
|
+
pickupLocations={product.pickupLocations}
|
|
903
|
+
destinations={product.destinations}
|
|
904
|
+
pickupLocationId={pickupLocationId}
|
|
905
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
906
|
+
selectedPickupLocation={selectedPickupLocation}
|
|
907
|
+
highlightedPickupLocationIds={highlightedPickupLocationIds}
|
|
908
|
+
hidePickupSkipOption={hidePickupSkipOption}
|
|
909
|
+
onLocationSelect={handleCheckoutPickupLocationSelect}
|
|
910
|
+
onSkipPickup={handleCheckoutPickupSkip}
|
|
911
|
+
onChangePickup={handleCheckoutPickupChange}
|
|
912
|
+
termsAccepted={termsAccepted}
|
|
913
|
+
onTermsChange={handleTermsChange}
|
|
3815
914
|
isAdmin={isAdmin}
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
locale={locale}
|
|
3831
|
-
onSelectionsChange={updateAddOnSelections}
|
|
3832
|
-
minimumTotalByAddOnId={undefined}
|
|
915
|
+
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
916
|
+
disableAutoCommunications={disableAutoCommunications}
|
|
917
|
+
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
918
|
+
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
919
|
+
error={checkoutFormError}
|
|
920
|
+
loading={loading}
|
|
921
|
+
totalQuantity={totalQuantity}
|
|
922
|
+
onCheckout={handleCheckout}
|
|
923
|
+
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
924
|
+
hideSubmitButton={showCheckoutModal || showAdminPaymentChoice}
|
|
925
|
+
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
926
|
+
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
927
|
+
attributionConfirmed={partnerAttributionConfirmed}
|
|
928
|
+
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
3833
929
|
/>
|
|
3834
930
|
)}
|
|
3835
|
-
|
|
3836
|
-
{/* Total and Checkout — shared PriceSummary component */}
|
|
3837
|
-
{!selectedBookingOptionsHydrating && selectedAvailability && (
|
|
3838
|
-
<>
|
|
3839
|
-
<CheckoutForm
|
|
3840
|
-
priceSummaryLines={displayCheckoutPriceSummaryLines}
|
|
3841
|
-
totalPrice={displayTotalPrice}
|
|
3842
|
-
totalSummaryLabel={undefined}
|
|
3843
|
-
subtotal={
|
|
3844
|
-
subtotal !== totalFromSummary ||
|
|
3845
|
-
effectivePromoDiscountAmount > 0 ||
|
|
3846
|
-
addOnTotal > 0 ||
|
|
3847
|
-
(priceSummaryAugmentation?.subtotalAdjustment ?? 0) !== 0
|
|
3848
|
-
? displaySubtotal
|
|
3849
|
-
: undefined
|
|
3850
|
-
}
|
|
3851
|
-
taxAmount={
|
|
3852
|
-
!isTaxIncludedInPrice &&
|
|
3853
|
-
(effectivePromoDiscountAmount > 0 ? effectiveTax : tax) > 0
|
|
3854
|
-
? (effectivePromoDiscountAmount > 0 ? effectiveTax : tax)
|
|
3855
|
-
: 0
|
|
3856
|
-
}
|
|
3857
|
-
taxRate={pricingConfig?.taxRate}
|
|
3858
|
-
currency={currency}
|
|
3859
|
-
locale={locale}
|
|
3860
|
-
t={t}
|
|
3861
|
-
extraBetweenTaxAndTotal={
|
|
3862
|
-
<PromoCodeInput
|
|
3863
|
-
promoCodeInput={promoCodeInput}
|
|
3864
|
-
appliedPromoCode={appliedPromoCode}
|
|
3865
|
-
promoCodeError={promoCodeError}
|
|
3866
|
-
promoCodeValidating={promoCodeValidating}
|
|
3867
|
-
promoDiscountAmount={promoDiscountAmount}
|
|
3868
|
-
currency={currency}
|
|
3869
|
-
locale={locale}
|
|
3870
|
-
t={t}
|
|
3871
|
-
onInputChange={(v) => {
|
|
3872
|
-
setPromoCodeInput(v);
|
|
3873
|
-
setPromoCodeError('');
|
|
3874
|
-
}}
|
|
3875
|
-
onApply={handleApplyPromo}
|
|
3876
|
-
onRemove={() => {
|
|
3877
|
-
promoAppliedSelectionKeyRef.current = null;
|
|
3878
|
-
setAppliedPromoCode(null);
|
|
3879
|
-
setPromoCodeInput('');
|
|
3880
|
-
setPromoCodeError('');
|
|
3881
|
-
setForcedCancellationPolicy(null);
|
|
3882
|
-
setCancellationPolicyId(null); // Reset so auto-select effect picks cheapest
|
|
3883
|
-
fetchedRangesRef.current = [];
|
|
3884
|
-
}}
|
|
3885
|
-
/>
|
|
3886
|
-
}
|
|
3887
|
-
firstName={firstName}
|
|
3888
|
-
lastName={lastName}
|
|
3889
|
-
email={email}
|
|
3890
|
-
onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
|
|
3891
|
-
onLastNameChange={(v) => { setLastName(v); setError(''); }}
|
|
3892
|
-
onEmailChange={(v) => { setEmail(v); setError(''); }}
|
|
3893
|
-
phoneNumber={phoneNumber}
|
|
3894
|
-
onPhoneNumberChange={(v) => { setPhoneNumber(v); setError(''); }}
|
|
3895
|
-
readOnlyContactFields={false}
|
|
3896
|
-
pickupLocations={
|
|
3897
|
-
selectedDate && product.pickupLocations && product.pickupLocations.length > 0
|
|
3898
|
-
? product.pickupLocations
|
|
3899
|
-
: undefined
|
|
3900
|
-
}
|
|
3901
|
-
destinations={product.destinations}
|
|
3902
|
-
pickupLocationId={pickupLocationId}
|
|
3903
|
-
pickupLocationSkipped={pickupLocationSkipped}
|
|
3904
|
-
selectedPickupLocation={selectedPickupLocation}
|
|
3905
|
-
highlightedPickupLocationIds={highlightedPickupLocationIds}
|
|
3906
|
-
hidePickupSkipOption={hidePickupSkipOption}
|
|
3907
|
-
onLocationSelect={(locationId) => {
|
|
3908
|
-
setPickupLocationId(locationId);
|
|
3909
|
-
setError('');
|
|
3910
|
-
if (locationId === null && pickupLocationSkipped) {
|
|
3911
|
-
setPickupLocationSkipped(false);
|
|
3912
|
-
} else if (locationId !== null) {
|
|
3913
|
-
setPickupLocationSkipped(false);
|
|
3914
|
-
}
|
|
3915
|
-
}}
|
|
3916
|
-
onSkip={() => {
|
|
3917
|
-
if (hidePickupSkipOption) return;
|
|
3918
|
-
setPickupLocationSkipped(true);
|
|
3919
|
-
setPickupLocationId(null);
|
|
3920
|
-
setError('');
|
|
3921
|
-
}}
|
|
3922
|
-
onChangePickup={() => {
|
|
3923
|
-
setPickupLocationId(null);
|
|
3924
|
-
setPickupLocationSkipped(false);
|
|
3925
|
-
}}
|
|
3926
|
-
termsAccepted={termsAccepted}
|
|
3927
|
-
onTermsChange={(checked) => {
|
|
3928
|
-
setTermsAccepted(checked);
|
|
3929
|
-
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
3930
|
-
}}
|
|
3931
|
-
isAdmin={isAdmin}
|
|
3932
|
-
showCommunicationAdminSection
|
|
3933
|
-
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
3934
|
-
disableAutoCommunications={disableAutoCommunications}
|
|
3935
|
-
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
3936
|
-
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
3937
|
-
error={checkoutFormError}
|
|
3938
|
-
loading={loading}
|
|
3939
|
-
totalQuantity={totalQuantity}
|
|
3940
|
-
onCheckout={handleCheckout}
|
|
3941
|
-
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
3942
|
-
hideSubmitButton={showCheckoutModal || showAdminPaymentChoice}
|
|
3943
|
-
submitDisabled={false}
|
|
3944
|
-
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
3945
|
-
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
3946
|
-
attributionConfirmed={partnerAttributionConfirmed}
|
|
3947
|
-
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
3948
|
-
/>
|
|
3949
|
-
</>
|
|
3950
|
-
)}
|
|
3951
931
|
</div>
|
|
3952
932
|
</>
|
|
3953
933
|
)}
|