@ticketboothapp/booking 1.2.59 → 1.2.61
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.
|
@@ -47,9 +47,20 @@ import { useTranslations, useLocale } from '../../lib/booking/i18n';
|
|
|
47
47
|
import { type Currency } from './CurrencySwitcher';
|
|
48
48
|
import { formatBookingRefForDisplay } from '../../lib/booking-ref';
|
|
49
49
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
effectiveProductOptionIdForChangeFlow,
|
|
51
|
+
isParentProductId,
|
|
52
|
+
normalizeProductOptionIdForChangeFlow,
|
|
53
|
+
} from '../../lib/booking/product-option-id';
|
|
54
|
+
import { formatCurrencyAmount } from '../../lib/currency';
|
|
55
|
+
import {
|
|
56
|
+
resolveChangeFlowNewBookingTotal,
|
|
57
|
+
changeFlowBalanceVsOriginal,
|
|
58
|
+
resolveChangeFlowDisplayedAmounts,
|
|
59
|
+
normalizeNearZeroOwed,
|
|
60
|
+
changeFlowTicketLineTotalWithReceiptFloor,
|
|
61
|
+
changeFlowFeeLineTotalWithReceiptFloor,
|
|
62
|
+
changeFlowReturnPerPersonWithReceiptFloor,
|
|
63
|
+
} from '../../lib/booking/change-flow-pricing';
|
|
53
64
|
import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
|
|
54
65
|
import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
|
|
55
66
|
import { ItineraryStepType as StepType } from '../../lib/booking-api';
|
|
@@ -79,16 +90,6 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
|
79
90
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
80
91
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
81
92
|
|
|
82
|
-
/**
|
|
83
|
-
* Change-booking diagnostics in the console on local machines only.
|
|
84
|
-
* Uses hostname (not NODE_ENV): bundled packages often inline production, so `development` was never true in the browser.
|
|
85
|
-
*/
|
|
86
|
-
function isLocalhostChangeBookingDebug(): boolean {
|
|
87
|
-
if (typeof window === 'undefined') return false;
|
|
88
|
-
const h = window.location.hostname;
|
|
89
|
-
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
93
|
/** Live selection snapshot for change-booking compare UI (parent dialog). */
|
|
93
94
|
export interface ChangeFlowSelectionPreview {
|
|
94
95
|
tourName: string;
|
|
@@ -153,140 +154,6 @@ function normalizeLineLabelForCompare(label: string): string {
|
|
|
153
154
|
.trim();
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
/** Mirrors BookingFlow protected ticket subtotal for a given order summary snapshot. */
|
|
157
|
-
function computeChangeFlowProtectedTicketSubtotalForOrderSummary(
|
|
158
|
-
sameItinerary: boolean,
|
|
159
|
-
ticketLineItems: OrderSummary['ticketLineItems'],
|
|
160
|
-
ticketFloors: Map<string, number>,
|
|
161
|
-
initialQtyByCat: Map<string, number>,
|
|
162
|
-
): number {
|
|
163
|
-
if (!sameItinerary) {
|
|
164
|
-
return ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0);
|
|
165
|
-
}
|
|
166
|
-
return ticketLineItems.reduce((sum, line) => {
|
|
167
|
-
const category = line.category?.trim().toUpperCase();
|
|
168
|
-
const qty = Math.max(0, Number(line.qty) || 0);
|
|
169
|
-
if (!category || qty <= 0) return sum;
|
|
170
|
-
const bookedUnitPrice = ticketFloors.get(category);
|
|
171
|
-
if (bookedUnitPrice == null) return sum + line.itemTotal;
|
|
172
|
-
const baselineQty = initialQtyByCat.get(category) ?? 0;
|
|
173
|
-
const protectedQty = Math.min(qty, baselineQty);
|
|
174
|
-
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
175
|
-
const liveUnit = qty > 0 ? Math.max(0, Number(line.itemTotal) || 0) / qty : 0;
|
|
176
|
-
return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnit;
|
|
177
|
-
}, 0);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function computeChangeFlowProtectedFeeSubtotalForOrderSummary(
|
|
181
|
-
sameItinerary: boolean,
|
|
182
|
-
totalQuantity: number,
|
|
183
|
-
feeLineItems: OrderSummary['feeLineItems'],
|
|
184
|
-
feeFloors: Map<string, number>,
|
|
185
|
-
initialTicketCount: number,
|
|
186
|
-
): number {
|
|
187
|
-
if (!sameItinerary || totalQuantity <= 0) {
|
|
188
|
-
return feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0);
|
|
189
|
-
}
|
|
190
|
-
const protectedP = Math.min(initialTicketCount, totalQuantity);
|
|
191
|
-
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
192
|
-
return feeLineItems.reduce((sum, line) => {
|
|
193
|
-
const key = normalizeLineLabelForCompare(line.name || '');
|
|
194
|
-
const bookedUnitPrice = key ? feeFloors.get(key) : undefined;
|
|
195
|
-
if (bookedUnitPrice == null) return sum + line.totalAmount;
|
|
196
|
-
const liveTotal = Math.max(0, Number(line.totalAmount) || 0);
|
|
197
|
-
const livePer = totalQuantity > 0 ? liveTotal / totalQuantity : 0;
|
|
198
|
-
return sum + protectedP * bookedUnitPrice + incrementalP * livePer;
|
|
199
|
-
}, 0);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function computeChangeFlowProtectedReturnForOrderSummary(
|
|
203
|
-
sameItinerary: boolean,
|
|
204
|
-
totalQuantity: number,
|
|
205
|
-
returnPriceAdjustment: number,
|
|
206
|
-
returnFloorPerPerson: number | null,
|
|
207
|
-
initialTicketCount: number,
|
|
208
|
-
selectedReturnOption: ReturnOption | null,
|
|
209
|
-
currency: Currency,
|
|
210
|
-
): number {
|
|
211
|
-
if (!sameItinerary || totalQuantity <= 0) return returnPriceAdjustment;
|
|
212
|
-
if (returnFloorPerPerson == null) return returnPriceAdjustment;
|
|
213
|
-
const rawPerPerson = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
214
|
-
const protectedP = Math.min(initialTicketCount, totalQuantity);
|
|
215
|
-
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
216
|
-
return protectedP * returnFloorPerPerson + incrementalP * rawPerPerson;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function computeChangeFlowEffectiveSubtotalBeforeAddOnsForOrderSummary(
|
|
220
|
-
isChangeFlow: boolean,
|
|
221
|
-
sameItinerary: boolean,
|
|
222
|
-
os: OrderSummary,
|
|
223
|
-
ticketFloors: Map<string, number>,
|
|
224
|
-
initialQtyByCat: Map<string, number>,
|
|
225
|
-
feeFloors: Map<string, number>,
|
|
226
|
-
initialTicketCount: number,
|
|
227
|
-
returnFloorPerPerson: number | null,
|
|
228
|
-
selectedReturnOption: ReturnOption | null,
|
|
229
|
-
currency: Currency,
|
|
230
|
-
): number {
|
|
231
|
-
const { subtotal, ticketLineItems, feeLineItems, returnPriceAdjustment, totalQuantity } = os;
|
|
232
|
-
if (!isChangeFlow || !sameItinerary) return subtotal;
|
|
233
|
-
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
234
|
-
(s, l) => s + Math.max(0, Number(l.itemTotal) || 0),
|
|
235
|
-
0,
|
|
236
|
-
);
|
|
237
|
-
const currentFeeSubtotal = feeLineItems.reduce((s, l) => s + Math.max(0, Number(l.totalAmount) || 0), 0);
|
|
238
|
-
const protectedT = computeChangeFlowProtectedTicketSubtotalForOrderSummary(
|
|
239
|
-
sameItinerary,
|
|
240
|
-
ticketLineItems,
|
|
241
|
-
ticketFloors,
|
|
242
|
-
initialQtyByCat,
|
|
243
|
-
);
|
|
244
|
-
const protectedF = computeChangeFlowProtectedFeeSubtotalForOrderSummary(
|
|
245
|
-
sameItinerary,
|
|
246
|
-
totalQuantity,
|
|
247
|
-
feeLineItems,
|
|
248
|
-
feeFloors,
|
|
249
|
-
initialTicketCount,
|
|
250
|
-
);
|
|
251
|
-
const protectedR = computeChangeFlowProtectedReturnForOrderSummary(
|
|
252
|
-
sameItinerary,
|
|
253
|
-
totalQuantity,
|
|
254
|
-
returnPriceAdjustment,
|
|
255
|
-
returnFloorPerPerson,
|
|
256
|
-
initialTicketCount,
|
|
257
|
-
selectedReturnOption,
|
|
258
|
-
currency,
|
|
259
|
-
);
|
|
260
|
-
return (
|
|
261
|
-
subtotal -
|
|
262
|
-
currentTicketSubtotal -
|
|
263
|
-
currentFeeSubtotal -
|
|
264
|
-
returnPriceAdjustment +
|
|
265
|
-
protectedT +
|
|
266
|
-
protectedF +
|
|
267
|
-
protectedR
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Matches BookingFlow totalPrice from effectiveSubtotalBeforeAddOns + add-ons + tax − promo. */
|
|
272
|
-
function computeTotalPriceFromEffectiveSubtotalBeforeAddOns(
|
|
273
|
-
effectiveSubtotalBeforeAddOns: number,
|
|
274
|
-
addOnTotal: number,
|
|
275
|
-
effectivePromoDiscountAmount: number,
|
|
276
|
-
isGiftCard: boolean,
|
|
277
|
-
isVoucher: boolean,
|
|
278
|
-
isTaxIncludedInPrice: boolean,
|
|
279
|
-
pricingConfig: PricingConfig | null,
|
|
280
|
-
): number {
|
|
281
|
-
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
282
|
-
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
|
|
283
|
-
const effectiveTax =
|
|
284
|
-
effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
285
|
-
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
286
|
-
: taxOnSubtotal;
|
|
287
|
-
return effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
157
|
function deriveAddOnSelectionsFromReceiptLines(
|
|
291
158
|
addOns: AddOn[],
|
|
292
159
|
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
@@ -346,6 +213,92 @@ function findMergedAvailabilityForSelection(
|
|
|
346
213
|
return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
|
|
347
214
|
}
|
|
348
215
|
|
|
216
|
+
/** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
|
|
217
|
+
function buildPricingFromAvailability(
|
|
218
|
+
selectedAvailability: Availability | null,
|
|
219
|
+
activeOptions: Product['options'],
|
|
220
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
221
|
+
currency: Currency,
|
|
222
|
+
pricingConfig: PricingConfig | null,
|
|
223
|
+
hasFees: boolean,
|
|
224
|
+
isSimplifiedPricingView: boolean,
|
|
225
|
+
) {
|
|
226
|
+
if (!selectedAvailability || !pricingConfig) return [];
|
|
227
|
+
const optionId = selectedAvailability.productOptionId;
|
|
228
|
+
const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
|
|
229
|
+
const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
|
|
230
|
+
const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
|
|
231
|
+
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
232
|
+
const getBaseInDisplayCurrency = (category: string) => {
|
|
233
|
+
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
234
|
+
if (fromPrecomputed != null) return fromPrecomputed;
|
|
235
|
+
return 0;
|
|
236
|
+
};
|
|
237
|
+
const buildRate = (
|
|
238
|
+
category: string,
|
|
239
|
+
backendPriceCAD: number,
|
|
240
|
+
backendInDisplayCurrency: number,
|
|
241
|
+
baseInDisplayCurrency: number,
|
|
242
|
+
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
|
|
243
|
+
) => {
|
|
244
|
+
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
245
|
+
const isPublicMode = isSimplifiedPricingView;
|
|
246
|
+
const breakdown = computePriceBreakdown(
|
|
247
|
+
pricingConfig,
|
|
248
|
+
currency,
|
|
249
|
+
backendPriceCAD,
|
|
250
|
+
basePriceCAD,
|
|
251
|
+
hasFees,
|
|
252
|
+
appliedAdjustments,
|
|
253
|
+
undefined,
|
|
254
|
+
baseInDisplayCurrency,
|
|
255
|
+
isPublicMode,
|
|
256
|
+
);
|
|
257
|
+
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
258
|
+
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
259
|
+
};
|
|
260
|
+
return (
|
|
261
|
+
selectedAvailability.rates?.map((rate) => {
|
|
262
|
+
const backendPriceCAD = rate.price ?? 0;
|
|
263
|
+
const backendInDisplayCurrency =
|
|
264
|
+
rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
265
|
+
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
266
|
+
const built = buildRate(
|
|
267
|
+
rate.category,
|
|
268
|
+
backendPriceCAD,
|
|
269
|
+
backendInDisplayCurrency,
|
|
270
|
+
baseInDisplayCurrency,
|
|
271
|
+
rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
|
|
272
|
+
);
|
|
273
|
+
return {
|
|
274
|
+
category: rate.category,
|
|
275
|
+
rateId: rate.rateId || rate.category,
|
|
276
|
+
available: rate.available,
|
|
277
|
+
price: built.price,
|
|
278
|
+
priceCAD: built.priceCAD,
|
|
279
|
+
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
280
|
+
appliedAdjustments: built.appliedAdjustments,
|
|
281
|
+
};
|
|
282
|
+
}) ||
|
|
283
|
+
selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
|
|
284
|
+
const priceCADFromApi = p.price / 100;
|
|
285
|
+
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
286
|
+
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
287
|
+
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
288
|
+
return {
|
|
289
|
+
category: p.category,
|
|
290
|
+
rateId: p.category,
|
|
291
|
+
available: selectedAvailability.vacancies,
|
|
292
|
+
price: built.price,
|
|
293
|
+
priceCAD: built.priceCAD,
|
|
294
|
+
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
295
|
+
appliedAdjustments: built.appliedAdjustments,
|
|
296
|
+
};
|
|
297
|
+
}) ||
|
|
298
|
+
[]
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
349
302
|
function findMergedReturnVacancies(
|
|
350
303
|
outbound: Availability | undefined,
|
|
351
304
|
selectedReturn: ReturnOption | null | undefined
|
|
@@ -429,12 +382,16 @@ interface BookingFlowProps {
|
|
|
429
382
|
dateTime?: string | null;
|
|
430
383
|
/** Inventory slot id from booking (when API persists it); strongest match for change flow. */
|
|
431
384
|
availabilityId?: string | null;
|
|
385
|
+
/** Parent product id when option id is omitted or differs from availability rows (GYG/ticketbooth shape). */
|
|
386
|
+
productId?: string | null;
|
|
432
387
|
productOptionId?: string | null;
|
|
433
388
|
pickupLocationId?: string | null;
|
|
434
389
|
/** Original booked return slot (round-trip), when API provides it. */
|
|
435
390
|
returnAvailabilityId?: string | null;
|
|
436
391
|
/** Fallback when only return datetime is on the booking payload. */
|
|
437
392
|
returnDateTime?: string | null;
|
|
393
|
+
/** Persisted return floor per person from booking API (must match ticketbooth `return_unit_floor_per_person`). */
|
|
394
|
+
returnUnitFloorPerPerson?: number | null;
|
|
438
395
|
bookingItems?: Array<{ category: string; count: number }> | null;
|
|
439
396
|
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
440
397
|
customer?: {
|
|
@@ -910,6 +867,18 @@ export function BookingFlow({
|
|
|
910
867
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
911
868
|
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
912
869
|
const isChangeFlow = mode === 'change';
|
|
870
|
+
const changeFlowOriginalDate = useMemo(() => {
|
|
871
|
+
if (!isChangeFlow || !initialValues?.dateTime?.trim()) return null;
|
|
872
|
+
try {
|
|
873
|
+
return formatInTimeZone(
|
|
874
|
+
parseAvailabilityDateTime(initialValues.dateTime.trim()),
|
|
875
|
+
companyTimezone,
|
|
876
|
+
'yyyy-MM-dd',
|
|
877
|
+
);
|
|
878
|
+
} catch {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}, [isChangeFlow, initialValues?.dateTime, companyTimezone]);
|
|
913
882
|
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
914
883
|
const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
|
|
915
884
|
|
|
@@ -1720,6 +1689,83 @@ export function BookingFlow({
|
|
|
1720
1689
|
[timesForSelectedDate],
|
|
1721
1690
|
);
|
|
1722
1691
|
|
|
1692
|
+
/**
|
|
1693
|
+
* Bookings often store only parent `p_…` (no `po_…`). Resolve the booked option id from inventory rows,
|
|
1694
|
+
* wall-clock match, or single active option — must be defined before effects that seed selection / seat credit.
|
|
1695
|
+
*/
|
|
1696
|
+
const changeFlowResolvedInitialProductOptionId = useMemo((): string | null => {
|
|
1697
|
+
if (!isChangeFlow) return null;
|
|
1698
|
+
const fromFields = effectiveProductOptionIdForChangeFlow({
|
|
1699
|
+
productId: initialValues?.productId,
|
|
1700
|
+
productOptionId: initialValues?.productOptionId,
|
|
1701
|
+
});
|
|
1702
|
+
if (fromFields) return fromFields;
|
|
1703
|
+
|
|
1704
|
+
const aid = initialValues?.availabilityId?.trim();
|
|
1705
|
+
if (aid) {
|
|
1706
|
+
for (const a of availabilities) {
|
|
1707
|
+
if (a.availabilityId === aid) {
|
|
1708
|
+
const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
|
|
1709
|
+
if (po) return po;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (initialValues?.dateTime?.trim()) {
|
|
1715
|
+
try {
|
|
1716
|
+
const initialDt = parseAvailabilityDateTime(initialValues.dateTime.trim());
|
|
1717
|
+
const initialMs = initialDt.getTime();
|
|
1718
|
+
const initialDay = formatInTimeZone(initialDt, companyTimezone, 'yyyy-MM-dd');
|
|
1719
|
+
for (const a of availabilities) {
|
|
1720
|
+
const avDt = parseAvailabilityDateTime(a.dateTime);
|
|
1721
|
+
if (formatInTimeZone(avDt, companyTimezone, 'yyyy-MM-dd') !== initialDay) continue;
|
|
1722
|
+
if (avDt.getTime() !== initialMs) continue;
|
|
1723
|
+
const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
|
|
1724
|
+
if (po) return po;
|
|
1725
|
+
}
|
|
1726
|
+
} catch {
|
|
1727
|
+
/* ignore */
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (activeOptions.length === 1) {
|
|
1732
|
+
return normalizeProductOptionIdForChangeFlow(activeOptions[0].optionId);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
return null;
|
|
1736
|
+
}, [
|
|
1737
|
+
isChangeFlow,
|
|
1738
|
+
initialValues?.productId,
|
|
1739
|
+
initialValues?.productOptionId,
|
|
1740
|
+
initialValues?.availabilityId,
|
|
1741
|
+
initialValues?.dateTime,
|
|
1742
|
+
availabilities,
|
|
1743
|
+
activeOptions,
|
|
1744
|
+
companyTimezone,
|
|
1745
|
+
]);
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Parent catalog product id (`p_…`) for minimum-paid receipt floors: explicit parent on the booking,
|
|
1749
|
+
* otherwise the product loaded for this change session (booking payload may only carry `po_…`).
|
|
1750
|
+
*/
|
|
1751
|
+
const changeFlowBookingParentProductIdForFloors = useMemo(() => {
|
|
1752
|
+
const pid = initialValues?.productId?.trim();
|
|
1753
|
+
if (pid && isParentProductId(pid)) return pid;
|
|
1754
|
+
return product.productId.trim();
|
|
1755
|
+
}, [initialValues?.productId, product.productId]);
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* Receipt paid floors (max(receipt, catalog) on protected seats / return) — **customer self-serve only**.
|
|
1759
|
+
* Provider dashboard uses live catalog so cheaper itinerary → lower total / refund (`changeFlowBalanceVsOriginal` provider).
|
|
1760
|
+
*/
|
|
1761
|
+
const changeFlowApplyReceiptPaidFloors = useMemo(
|
|
1762
|
+
() =>
|
|
1763
|
+
isChangeFlow &&
|
|
1764
|
+
!isProviderDashboardChange &&
|
|
1765
|
+
changeFlowBookingParentProductIdForFloors === product.productId.trim(),
|
|
1766
|
+
[isChangeFlow, isProviderDashboardChange, changeFlowBookingParentProductIdForFloors, product.productId],
|
|
1767
|
+
);
|
|
1768
|
+
|
|
1723
1769
|
useEffect(() => {
|
|
1724
1770
|
if (hasAppliedInitialValuesRef.current || !initialValues) return;
|
|
1725
1771
|
const trimmedEmail = initialValues.customer?.email?.trim();
|
|
@@ -1760,7 +1806,7 @@ export function BookingFlow({
|
|
|
1760
1806
|
target,
|
|
1761
1807
|
companyTimezone,
|
|
1762
1808
|
initialValues.availabilityId ?? null,
|
|
1763
|
-
initialValues.productOptionId ?? null,
|
|
1809
|
+
changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
|
|
1764
1810
|
initialValues.bookingItems ?? null,
|
|
1765
1811
|
precomputedPricesByOption,
|
|
1766
1812
|
currency,
|
|
@@ -1776,6 +1822,7 @@ export function BookingFlow({
|
|
|
1776
1822
|
initialValues?.availabilityId,
|
|
1777
1823
|
initialValues?.productOptionId,
|
|
1778
1824
|
initialValues?.bookingItems,
|
|
1825
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1779
1826
|
selectedAvailability,
|
|
1780
1827
|
selectedDate,
|
|
1781
1828
|
timesForSelectedDate,
|
|
@@ -1943,14 +1990,20 @@ export function BookingFlow({
|
|
|
1943
1990
|
const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
|
|
1944
1991
|
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
1945
1992
|
if (selectedMs !== initialMs) return 0;
|
|
1946
|
-
const
|
|
1947
|
-
const
|
|
1948
|
-
if (
|
|
1949
|
-
|
|
1993
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
|
|
1994
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
1995
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
1996
|
+
return selectedOpt === initialOpt ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1997
|
+
}
|
|
1998
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
1999
|
+
const legacySelected = availability.productOptionId?.trim() || null;
|
|
2000
|
+
if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCountForSeatCredit;
|
|
2001
|
+
return legacySelected === legacyInitial ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1950
2002
|
},
|
|
1951
2003
|
[
|
|
1952
2004
|
isChangeFlow,
|
|
1953
2005
|
changeFlowInitialTicketCountForSeatCredit,
|
|
2006
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1954
2007
|
initialValues?.availabilityId,
|
|
1955
2008
|
initialValues?.dateTime,
|
|
1956
2009
|
initialValues?.productOptionId,
|
|
@@ -2298,7 +2351,11 @@ export function BookingFlow({
|
|
|
2298
2351
|
return floors;
|
|
2299
2352
|
}, [isChangeFlow, originalReceipt?.lineItems]);
|
|
2300
2353
|
|
|
2301
|
-
|
|
2354
|
+
/**
|
|
2355
|
+
* When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
|
|
2356
|
+
* so catalog "free" return slots still show and price at the original return value in change flow.
|
|
2357
|
+
*/
|
|
2358
|
+
const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
|
|
2302
2359
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return null;
|
|
2303
2360
|
const fallbackBookedQty =
|
|
2304
2361
|
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
@@ -2306,7 +2363,7 @@ export function BookingFlow({
|
|
|
2306
2363
|
let totalQty = 0;
|
|
2307
2364
|
for (const line of originalReceipt.lineItems) {
|
|
2308
2365
|
const type = (line.type || '').trim().toUpperCase();
|
|
2309
|
-
if (type !== 'RETURN_OPTION') continue;
|
|
2366
|
+
if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
|
|
2310
2367
|
const amount = Number(line.amount ?? 0);
|
|
2311
2368
|
const qtyRaw = Number(line.quantity ?? 0);
|
|
2312
2369
|
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
@@ -2317,6 +2374,15 @@ export function BookingFlow({
|
|
|
2317
2374
|
if (totalAmount <= 0 || totalQty <= 0) return null;
|
|
2318
2375
|
return totalAmount / totalQty;
|
|
2319
2376
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2377
|
+
|
|
2378
|
+
const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
|
|
2379
|
+
if (!isChangeFlow) return null;
|
|
2380
|
+
const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
|
|
2381
|
+
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
|
|
2382
|
+
const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
|
|
2383
|
+
if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
|
|
2384
|
+
return null;
|
|
2385
|
+
}, [isChangeFlow, initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
|
|
2320
2386
|
const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
|
|
2321
2387
|
const feeUnitByLabel = new Map<string, number>();
|
|
2322
2388
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
|
|
@@ -2360,19 +2426,25 @@ export function BookingFlow({
|
|
|
2360
2426
|
Boolean(initialAvailabilityId && selectedAvailabilityId) &&
|
|
2361
2427
|
initialAvailabilityId === selectedAvailabilityId;
|
|
2362
2428
|
if (idsMatch) return true;
|
|
2363
|
-
// Same wall time +
|
|
2429
|
+
// Same wall time + same product option (ids may rotate on refresh).
|
|
2364
2430
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2365
2431
|
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2366
2432
|
if (selectedMs !== initialMs) return false;
|
|
2367
|
-
const
|
|
2368
|
-
const
|
|
2369
|
-
|
|
2433
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
2434
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
2435
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
2436
|
+
return selectedOpt === initialOpt;
|
|
2437
|
+
}
|
|
2438
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
2439
|
+
const legacySelected = selectedAvailability.productOptionId?.trim() || null;
|
|
2440
|
+
return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
|
|
2370
2441
|
}, [
|
|
2371
2442
|
isChangeFlow,
|
|
2372
2443
|
selectedAvailability,
|
|
2373
2444
|
initialValues?.availabilityId,
|
|
2374
2445
|
initialValues?.dateTime,
|
|
2375
2446
|
initialValues?.productOptionId,
|
|
2447
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2376
2448
|
]);
|
|
2377
2449
|
const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
|
|
2378
2450
|
if (!isChangeFlow) return false;
|
|
@@ -2410,25 +2482,18 @@ export function BookingFlow({
|
|
|
2410
2482
|
selectedReturnOption?.dateTime,
|
|
2411
2483
|
]);
|
|
2412
2484
|
/** Same outbound + return as original booking: incremental seats use live pricing; receipt floors apply only after itinerary changes. */
|
|
2413
|
-
const changeFlowSameItineraryAsOriginalBooking = useMemo(
|
|
2414
|
-
() =>
|
|
2415
|
-
isChangeFlow &&
|
|
2416
|
-
changeFlowOutboundMatchesOriginalSelection &&
|
|
2417
|
-
changeFlowReturnMatchesOriginalSelection,
|
|
2418
|
-
[isChangeFlow, changeFlowOutboundMatchesOriginalSelection, changeFlowReturnMatchesOriginalSelection],
|
|
2419
|
-
);
|
|
2420
|
-
|
|
2421
2485
|
const returnOptionsWithFloor = useMemo(() => {
|
|
2422
2486
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
2423
|
-
if (!isChangeFlow &&
|
|
2487
|
+
if (!isChangeFlow && effectiveChangeFlowReturnUnitFloorPerPerson == null) return options;
|
|
2424
2488
|
return options.map((opt) => {
|
|
2425
2489
|
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2426
2490
|
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2427
2491
|
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2492
|
+
// Floors on return cards only for self-serve; provider sees catalog prices (refunds when cheaper).
|
|
2428
2493
|
const applyReturnFloor =
|
|
2429
|
-
|
|
2494
|
+
effectiveChangeFlowReturnUnitFloorPerPerson != null && isCustomerSelfServeChange;
|
|
2430
2495
|
const flooredPerPerson = applyReturnFloor
|
|
2431
|
-
? Math.max(rawPerPerson,
|
|
2496
|
+
? Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson!)
|
|
2432
2497
|
: rawPerPerson;
|
|
2433
2498
|
if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
|
|
2434
2499
|
return {
|
|
@@ -2443,17 +2508,17 @@ export function BookingFlow({
|
|
|
2443
2508
|
}, [
|
|
2444
2509
|
selectedAvailability?.returnOptions,
|
|
2445
2510
|
isChangeFlow,
|
|
2446
|
-
|
|
2447
|
-
|
|
2511
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2512
|
+
isCustomerSelfServeChange,
|
|
2448
2513
|
currency,
|
|
2449
2514
|
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2450
2515
|
]);
|
|
2451
2516
|
|
|
2452
2517
|
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2453
|
-
if (!selectedReturnOption || !isChangeFlow ||
|
|
2454
|
-
if (
|
|
2518
|
+
if (!selectedReturnOption || !isChangeFlow || effectiveChangeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
|
|
2519
|
+
if (!isCustomerSelfServeChange) return selectedReturnOption;
|
|
2455
2520
|
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2456
|
-
const flooredPerPerson = Math.max(rawPerPerson,
|
|
2521
|
+
const flooredPerPerson = Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson);
|
|
2457
2522
|
if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
|
|
2458
2523
|
return {
|
|
2459
2524
|
...selectedReturnOption,
|
|
@@ -2465,15 +2530,16 @@ export function BookingFlow({
|
|
|
2465
2530
|
}, [
|
|
2466
2531
|
selectedReturnOption,
|
|
2467
2532
|
isChangeFlow,
|
|
2468
|
-
|
|
2469
|
-
|
|
2533
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2534
|
+
isCustomerSelfServeChange,
|
|
2470
2535
|
currency,
|
|
2471
2536
|
]);
|
|
2472
2537
|
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2538
|
+
/** Order-summary return row uses self-serve floors via [selectedReturnOptionWithFloor]; provider stays catalog-only. */
|
|
2539
|
+
const returnOptionForOrderSummary = useMemo(
|
|
2540
|
+
() => selectedReturnOptionWithFloor,
|
|
2541
|
+
[selectedReturnOptionWithFloor],
|
|
2542
|
+
);
|
|
2477
2543
|
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2478
2544
|
if (!selectedAvailability) return 0;
|
|
2479
2545
|
const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
|
|
@@ -2488,71 +2554,19 @@ export function BookingFlow({
|
|
|
2488
2554
|
}, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
|
|
2489
2555
|
|
|
2490
2556
|
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2491
|
-
const pricing = useMemo(
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
2498
|
-
const getBaseInDisplayCurrency = (category: string) => {
|
|
2499
|
-
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
2500
|
-
if (fromPrecomputed != null) return fromPrecomputed;
|
|
2501
|
-
return 0;
|
|
2502
|
-
};
|
|
2503
|
-
const buildRate = (
|
|
2504
|
-
category: string,
|
|
2505
|
-
backendPriceCAD: number,
|
|
2506
|
-
backendInDisplayCurrency: number,
|
|
2507
|
-
baseInDisplayCurrency: number,
|
|
2508
|
-
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>
|
|
2509
|
-
) => {
|
|
2510
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
2511
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
2512
|
-
const breakdown = computePriceBreakdown(
|
|
2513
|
-
pricingConfig,
|
|
2557
|
+
const pricing = useMemo(
|
|
2558
|
+
() =>
|
|
2559
|
+
buildPricingFromAvailability(
|
|
2560
|
+
selectedAvailability,
|
|
2561
|
+
activeOptions,
|
|
2562
|
+
precomputedPricesByOption,
|
|
2514
2563
|
currency,
|
|
2515
|
-
|
|
2516
|
-
basePriceCAD,
|
|
2564
|
+
pricingConfig,
|
|
2517
2565
|
hasFees,
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
);
|
|
2523
|
-
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
2524
|
-
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
2525
|
-
};
|
|
2526
|
-
return selectedAvailability.rates?.map(rate => {
|
|
2527
|
-
const backendPriceCAD = rate.price ?? 0;
|
|
2528
|
-
const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
2529
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
2530
|
-
const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
|
|
2531
|
-
return {
|
|
2532
|
-
category: rate.category,
|
|
2533
|
-
rateId: rate.rateId || rate.category,
|
|
2534
|
-
available: rate.available,
|
|
2535
|
-
price: built.price,
|
|
2536
|
-
priceCAD: built.priceCAD,
|
|
2537
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2538
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
2539
|
-
};
|
|
2540
|
-
}) || selectedAvailability.pricesByCategory?.retailPrices?.map(p => {
|
|
2541
|
-
const priceCADFromApi = p.price / 100;
|
|
2542
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
2543
|
-
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
2544
|
-
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
2545
|
-
return {
|
|
2546
|
-
category: p.category,
|
|
2547
|
-
rateId: p.category,
|
|
2548
|
-
available: selectedAvailability.vacancies,
|
|
2549
|
-
price: built.price,
|
|
2550
|
-
priceCAD: built.priceCAD,
|
|
2551
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2552
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
2553
|
-
};
|
|
2554
|
-
}) || [];
|
|
2555
|
-
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
|
|
2566
|
+
isSimplifiedPricingView,
|
|
2567
|
+
),
|
|
2568
|
+
[selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
|
|
2569
|
+
);
|
|
2556
2570
|
|
|
2557
2571
|
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2558
2572
|
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
@@ -2588,62 +2602,29 @@ export function BookingFlow({
|
|
|
2588
2602
|
[quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2589
2603
|
);
|
|
2590
2604
|
|
|
2591
|
-
/** Initial booking ticket counts on the same pricing grid (for catalog delta vs receipt anchoring). */
|
|
2592
|
-
const changeFlowBaselineQuantitiesForSummary = useMemo((): Record<string, number> => {
|
|
2593
|
-
const next: Record<string, number> = {};
|
|
2594
|
-
for (const rate of pricing) {
|
|
2595
|
-
next[rate.category] = 0;
|
|
2596
|
-
}
|
|
2597
|
-
for (const item of initialValues?.bookingItems ?? []) {
|
|
2598
|
-
const c = item.category?.trim();
|
|
2599
|
-
if (c) next[c] = Math.max(0, Number(item.count) || 0);
|
|
2600
|
-
}
|
|
2601
|
-
return next;
|
|
2602
|
-
}, [pricing, initialValues?.bookingItems]);
|
|
2603
|
-
|
|
2604
|
-
const orderSummaryChangeFlowBaseline: OrderSummary = useMemo(
|
|
2605
|
-
() =>
|
|
2606
|
-
computeOrderSummary(
|
|
2607
|
-
changeFlowBaselineQuantitiesForSummary,
|
|
2608
|
-
pricing,
|
|
2609
|
-
returnOptionForOrderSummary,
|
|
2610
|
-
pricingConfig ?? null,
|
|
2611
|
-
currency,
|
|
2612
|
-
hasFees,
|
|
2613
|
-
cancellationPolicyId
|
|
2614
|
-
),
|
|
2615
|
-
[
|
|
2616
|
-
changeFlowBaselineQuantitiesForSummary,
|
|
2617
|
-
pricing,
|
|
2618
|
-
returnOptionForOrderSummary,
|
|
2619
|
-
pricingConfig,
|
|
2620
|
-
currency,
|
|
2621
|
-
hasFees,
|
|
2622
|
-
cancellationPolicyId,
|
|
2623
|
-
],
|
|
2624
|
-
);
|
|
2625
|
-
|
|
2626
2605
|
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2627
2606
|
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2628
2607
|
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2629
2608
|
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2630
2609
|
0,
|
|
2631
2610
|
);
|
|
2632
|
-
if (!
|
|
2611
|
+
if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
|
|
2633
2612
|
return ticketLineItems.reduce((sum, line) => {
|
|
2634
2613
|
const category = line.category?.trim().toUpperCase();
|
|
2635
2614
|
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2636
2615
|
if (!category || qty <= 0) return sum;
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2616
|
+
return (
|
|
2617
|
+
sum +
|
|
2618
|
+
changeFlowTicketLineTotalWithReceiptFloor({
|
|
2619
|
+
qty,
|
|
2620
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2621
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2622
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2623
|
+
})
|
|
2624
|
+
);
|
|
2644
2625
|
}, 0);
|
|
2645
2626
|
}, [
|
|
2646
|
-
|
|
2627
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2647
2628
|
ticketLineItems,
|
|
2648
2629
|
changeFlowTicketBookedUnitPriceByCategory,
|
|
2649
2630
|
changeFlowInitialTicketQtyByCategory,
|
|
@@ -2736,45 +2717,98 @@ export function BookingFlow({
|
|
|
2736
2717
|
[feeLineItems],
|
|
2737
2718
|
);
|
|
2738
2719
|
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2739
|
-
if (!
|
|
2720
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2740
2721
|
const initialParty = changeFlowInitialTicketCount;
|
|
2741
|
-
const protectedP = Math.min(initialParty, totalQuantity);
|
|
2742
|
-
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
2743
2722
|
return feeLineItems.reduce((sum, line) => {
|
|
2744
2723
|
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2724
|
+
const bookedFeePerPerson = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : undefined;
|
|
2725
|
+
return (
|
|
2726
|
+
sum +
|
|
2727
|
+
changeFlowFeeLineTotalWithReceiptFloor({
|
|
2728
|
+
totalQuantity,
|
|
2729
|
+
initialTicketCount: initialParty,
|
|
2730
|
+
bookedFeePerPerson,
|
|
2731
|
+
liveFeeLineTotal: Number(line.totalAmount) || 0,
|
|
2732
|
+
})
|
|
2733
|
+
);
|
|
2750
2734
|
}, 0);
|
|
2751
2735
|
}, [
|
|
2752
|
-
|
|
2736
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2753
2737
|
totalQuantity,
|
|
2754
2738
|
currentFeeSubtotal,
|
|
2755
2739
|
feeLineItems,
|
|
2756
2740
|
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2757
2741
|
changeFlowInitialTicketCount,
|
|
2758
2742
|
]);
|
|
2743
|
+
/** Catalog (unfloored) return price per person — same slot as [selectedReturnOption] on the raw availability list. */
|
|
2744
|
+
const returnOptionCatalogPerPerson = useMemo(() => {
|
|
2745
|
+
if (!selectedReturnOption?.returnAvailabilityId || !selectedAvailability?.returnOptions?.length) {
|
|
2746
|
+
return null;
|
|
2747
|
+
}
|
|
2748
|
+
const raw = selectedAvailability.returnOptions.find(
|
|
2749
|
+
(o) => o.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
2750
|
+
);
|
|
2751
|
+
const v = raw?.priceAdjustmentByCurrency?.[currency];
|
|
2752
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
2753
|
+
}, [selectedReturnOption?.returnAvailabilityId, selectedAvailability?.returnOptions, currency]);
|
|
2754
|
+
|
|
2759
2755
|
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2760
|
-
if (!
|
|
2761
|
-
if (
|
|
2762
|
-
const
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
|
|
2766
|
-
|
|
2756
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return returnPriceAdjustment;
|
|
2757
|
+
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2758
|
+
const livePerPerson =
|
|
2759
|
+
returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
|
|
2760
|
+
const perPerson = changeFlowReturnPerPersonWithReceiptFloor({
|
|
2761
|
+
livePerPerson,
|
|
2762
|
+
receiptFloorPerPerson: effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2763
|
+
});
|
|
2764
|
+
return totalQuantity * perPerson;
|
|
2767
2765
|
}, [
|
|
2768
|
-
|
|
2766
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2769
2767
|
totalQuantity,
|
|
2770
2768
|
returnPriceAdjustment,
|
|
2771
|
-
|
|
2772
|
-
changeFlowInitialTicketCount,
|
|
2769
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2773
2770
|
selectedReturnOption,
|
|
2771
|
+
returnOptionCatalogPerPerson,
|
|
2774
2772
|
currency,
|
|
2775
2773
|
]);
|
|
2774
|
+
|
|
2775
|
+
/** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
|
|
2776
|
+
const checkoutReturnLineAmount = useMemo(() => {
|
|
2777
|
+
if (isChangeFlow && changeFlowApplyReceiptPaidFloors) {
|
|
2778
|
+
return changeFlowProtectedReturnAdjustment;
|
|
2779
|
+
}
|
|
2780
|
+
return returnPriceAdjustment;
|
|
2781
|
+
}, [
|
|
2782
|
+
isChangeFlow,
|
|
2783
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2784
|
+
changeFlowProtectedReturnAdjustment,
|
|
2785
|
+
returnPriceAdjustment,
|
|
2786
|
+
]);
|
|
2787
|
+
|
|
2788
|
+
/** Ticket lines with receipt floors applied for breakdown/modal (matches protected ticket subtotal). */
|
|
2789
|
+
const ticketLineItemsForChangeFlowDisplay = useMemo(() => {
|
|
2790
|
+
if (!changeFlowApplyReceiptPaidFloors) return ticketLineItems;
|
|
2791
|
+
return ticketLineItems.map((line) => {
|
|
2792
|
+
const category = line.category?.trim().toUpperCase();
|
|
2793
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2794
|
+
if (!category || qty <= 0) return line;
|
|
2795
|
+
const newTotal = changeFlowTicketLineTotalWithReceiptFloor({
|
|
2796
|
+
qty,
|
|
2797
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2798
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2799
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2800
|
+
});
|
|
2801
|
+
return { ...line, itemTotal: newTotal };
|
|
2802
|
+
});
|
|
2803
|
+
}, [
|
|
2804
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2805
|
+
ticketLineItems,
|
|
2806
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2807
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2808
|
+
]);
|
|
2809
|
+
|
|
2776
2810
|
const effectiveSubtotalBeforeAddOns =
|
|
2777
|
-
isChangeFlow &&
|
|
2811
|
+
isChangeFlow && changeFlowApplyReceiptPaidFloors
|
|
2778
2812
|
? subtotal -
|
|
2779
2813
|
currentTicketSubtotal -
|
|
2780
2814
|
currentFeeSubtotal -
|
|
@@ -2841,8 +2875,11 @@ export function BookingFlow({
|
|
|
2841
2875
|
|
|
2842
2876
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2843
2877
|
if (!selectedAvailability) return [];
|
|
2878
|
+
const returnLineAmount = checkoutReturnLineAmount;
|
|
2879
|
+
const showReturnLine =
|
|
2880
|
+
Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
|
|
2844
2881
|
return [
|
|
2845
|
-
...
|
|
2882
|
+
...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
|
|
2846
2883
|
const rate = pricing.find((r) => r.category === line.category);
|
|
2847
2884
|
const breakdown = getPriceBreakdown(
|
|
2848
2885
|
line.category,
|
|
@@ -2865,12 +2902,12 @@ export function BookingFlow({
|
|
|
2865
2902
|
breakdown,
|
|
2866
2903
|
};
|
|
2867
2904
|
}),
|
|
2868
|
-
...(
|
|
2905
|
+
...(showReturnLine
|
|
2869
2906
|
? [
|
|
2870
2907
|
{
|
|
2871
2908
|
kind: 'line' as const,
|
|
2872
2909
|
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
2873
|
-
amount:
|
|
2910
|
+
amount: returnLineAmount,
|
|
2874
2911
|
type: 'return',
|
|
2875
2912
|
},
|
|
2876
2913
|
]
|
|
@@ -2925,11 +2962,11 @@ export function BookingFlow({
|
|
|
2925
2962
|
];
|
|
2926
2963
|
}, [
|
|
2927
2964
|
selectedAvailability,
|
|
2928
|
-
|
|
2965
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
2929
2966
|
pricing,
|
|
2930
2967
|
getPriceBreakdown,
|
|
2931
2968
|
selectedReturnOption,
|
|
2932
|
-
|
|
2969
|
+
checkoutReturnLineAmount,
|
|
2933
2970
|
totalQuantity,
|
|
2934
2971
|
t,
|
|
2935
2972
|
cancellationPolicyFee,
|
|
@@ -3066,93 +3103,13 @@ export function BookingFlow({
|
|
|
3066
3103
|
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3067
3104
|
: taxOnSubtotal;
|
|
3068
3105
|
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
3069
|
-
/**
|
|
3070
|
-
const
|
|
3071
|
-
? reconcileChangeBookingProposedTotal(Math.round(totalPrice * 100) / 100, originalReceipt.total)
|
|
3072
|
-
: totalPrice;
|
|
3073
|
-
|
|
3074
|
-
const changeFlowBaselineEffectiveSubtotalBeforeAddOns = useMemo(() => {
|
|
3075
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveSubtotalBeforeAddOns;
|
|
3076
|
-
return computeChangeFlowEffectiveSubtotalBeforeAddOnsForOrderSummary(
|
|
3077
|
-
true,
|
|
3078
|
-
true,
|
|
3079
|
-
orderSummaryChangeFlowBaseline,
|
|
3080
|
-
changeFlowTicketBookedUnitPriceByCategory,
|
|
3081
|
-
changeFlowInitialTicketQtyByCategory,
|
|
3082
|
-
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
3083
|
-
changeFlowInitialTicketCount,
|
|
3084
|
-
changeFlowReturnUnitFloorPerPerson,
|
|
3085
|
-
selectedReturnOption,
|
|
3086
|
-
currency,
|
|
3087
|
-
);
|
|
3088
|
-
}, [
|
|
3089
|
-
isChangeFlow,
|
|
3090
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3091
|
-
effectiveSubtotalBeforeAddOns,
|
|
3092
|
-
orderSummaryChangeFlowBaseline,
|
|
3093
|
-
changeFlowTicketBookedUnitPriceByCategory,
|
|
3094
|
-
changeFlowInitialTicketQtyByCategory,
|
|
3095
|
-
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
3096
|
-
changeFlowInitialTicketCount,
|
|
3097
|
-
changeFlowReturnUnitFloorPerPerson,
|
|
3098
|
-
selectedReturnOption,
|
|
3099
|
-
currency,
|
|
3100
|
-
]);
|
|
3101
|
-
|
|
3102
|
-
const changeFlowBaselineEffectiveSubtotalFull = useMemo(() => {
|
|
3103
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveSubtotal;
|
|
3104
|
-
return changeFlowBaselineEffectiveSubtotalBeforeAddOns + addOnTotal;
|
|
3105
|
-
}, [
|
|
3106
|
-
isChangeFlow,
|
|
3107
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3108
|
-
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3109
|
-
addOnTotal,
|
|
3110
|
-
effectiveSubtotal,
|
|
3111
|
-
]);
|
|
3112
|
-
|
|
3113
|
-
const changeFlowBaselineEffectiveTax = useMemo(() => {
|
|
3114
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveTax;
|
|
3115
|
-
const es = changeFlowBaselineEffectiveSubtotalFull;
|
|
3116
|
-
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : es * (pricingConfig?.taxRate ?? 0);
|
|
3117
|
-
return effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
3118
|
-
? (es - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3119
|
-
: taxOnSubtotal;
|
|
3120
|
-
}, [
|
|
3106
|
+
/** Change-flow product rules: `lib/booking/change-flow-pricing.ts`. */
|
|
3107
|
+
const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
|
|
3121
3108
|
isChangeFlow,
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
isTaxIncludedInPrice,
|
|
3126
|
-
pricingConfig?.taxRate,
|
|
3127
|
-
effectivePromoDiscountAmount,
|
|
3128
|
-
isGiftCard,
|
|
3129
|
-
isVoucher,
|
|
3130
|
-
]);
|
|
3109
|
+
cartTotal: totalPrice,
|
|
3110
|
+
originalReceiptTotal: originalReceipt?.total,
|
|
3111
|
+
});
|
|
3131
3112
|
|
|
3132
|
-
const changeFlowBaselineTotalPrice = useMemo(() => {
|
|
3133
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) {
|
|
3134
|
-
return totalPrice;
|
|
3135
|
-
}
|
|
3136
|
-
return computeTotalPriceFromEffectiveSubtotalBeforeAddOns(
|
|
3137
|
-
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3138
|
-
addOnTotal,
|
|
3139
|
-
effectivePromoDiscountAmount,
|
|
3140
|
-
isGiftCard,
|
|
3141
|
-
isVoucher,
|
|
3142
|
-
isTaxIncludedInPrice,
|
|
3143
|
-
pricingConfig,
|
|
3144
|
-
);
|
|
3145
|
-
}, [
|
|
3146
|
-
isChangeFlow,
|
|
3147
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3148
|
-
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3149
|
-
addOnTotal,
|
|
3150
|
-
effectivePromoDiscountAmount,
|
|
3151
|
-
isGiftCard,
|
|
3152
|
-
isVoucher,
|
|
3153
|
-
isTaxIncludedInPrice,
|
|
3154
|
-
pricingConfig,
|
|
3155
|
-
]);
|
|
3156
3113
|
const changeSelectionDetails = useMemo(() => {
|
|
3157
3114
|
if (!isChangeFlow || !initialValues) {
|
|
3158
3115
|
return {
|
|
@@ -3184,11 +3141,12 @@ export function BookingFlow({
|
|
|
3184
3141
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
3185
3142
|
// Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
|
|
3186
3143
|
const dateChanged = initialMs != null && initialMs !== selectedMs;
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3144
|
+
const initialOpt =
|
|
3145
|
+
changeFlowResolvedInitialProductOptionId ??
|
|
3146
|
+
(initialValues.productOptionId?.trim() || null);
|
|
3147
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
3190
3148
|
const optionChanged = Boolean(
|
|
3191
|
-
|
|
3149
|
+
selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
|
|
3192
3150
|
);
|
|
3193
3151
|
const normalizePickupId = (value: string | null | undefined) => {
|
|
3194
3152
|
const trimmed = value?.trim();
|
|
@@ -3288,6 +3246,7 @@ export function BookingFlow({
|
|
|
3288
3246
|
}, [
|
|
3289
3247
|
isChangeFlow,
|
|
3290
3248
|
initialValues,
|
|
3249
|
+
changeFlowResolvedInitialProductOptionId,
|
|
3291
3250
|
selectedAvailability,
|
|
3292
3251
|
selectedReturnOption,
|
|
3293
3252
|
implicitReturnBaselineId,
|
|
@@ -3349,167 +3308,29 @@ export function BookingFlow({
|
|
|
3349
3308
|
const hasEffectiveChangeSelection =
|
|
3350
3309
|
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
3351
3310
|
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
const
|
|
3363
|
-
isChangeFlow && originalReceipt
|
|
3364
|
-
? reconcileChangeBookingProposedTotal(
|
|
3365
|
-
Math.round(changeFlowReceiptAnchoredCatalogTotalRaw * 100) / 100,
|
|
3366
|
-
originalReceipt.total,
|
|
3367
|
-
)
|
|
3368
|
-
: changeFlowProposedTotal;
|
|
3369
|
-
|
|
3370
|
-
const displayChangeFlowProposedTotal = providerTotalsPreview
|
|
3371
|
-
? providerTotalsPreview.totalAmount
|
|
3372
|
-
: changeFlowProposedTotalResolved;
|
|
3373
|
-
const displayChangeFlowSubtotal = providerTotalsPreview
|
|
3374
|
-
? providerTotalsPreview.subtotalBeforeTax
|
|
3375
|
-
: isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3376
|
-
? Math.round(
|
|
3377
|
-
(originalReceipt.subtotal + (effectiveSubtotal - changeFlowBaselineEffectiveSubtotalFull)) * 100,
|
|
3378
|
-
) / 100
|
|
3379
|
-
: effectiveSubtotal;
|
|
3380
|
-
const displayChangeFlowTax = providerTotalsPreview
|
|
3381
|
-
? providerTotalsPreview.taxAmount
|
|
3382
|
-
: isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3383
|
-
? Math.round((originalReceipt.tax + (effectiveTax - changeFlowBaselineEffectiveTax)) * 100) / 100
|
|
3384
|
-
: effectiveTax;
|
|
3311
|
+
const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
|
|
3312
|
+
providerPreview: providerTotalsPreview,
|
|
3313
|
+
fromCart: {
|
|
3314
|
+
total: changeFlowNewBookingTotal,
|
|
3315
|
+
subtotal: effectiveSubtotal,
|
|
3316
|
+
tax: effectiveTax,
|
|
3317
|
+
},
|
|
3318
|
+
});
|
|
3319
|
+
const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
|
|
3320
|
+
const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
|
|
3321
|
+
const displayChangeFlowTax = displayedChangeAmounts.tax;
|
|
3385
3322
|
|
|
3386
3323
|
const changeFlowClientEstimateDue = originalReceipt
|
|
3387
|
-
? (
|
|
3388
|
-
|
|
3389
|
-
:
|
|
3324
|
+
? changeFlowBalanceVsOriginal({
|
|
3325
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
3326
|
+
originalReceiptTotal: originalReceipt.total,
|
|
3327
|
+
audience: isProviderDashboardChange ? 'provider' : 'customer',
|
|
3328
|
+
})
|
|
3390
3329
|
: totalPrice;
|
|
3391
3330
|
|
|
3392
|
-
/**
|
|
3393
|
-
* Amount owed for change flow: FE proposed total vs original receipt.
|
|
3394
|
-
* Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
|
|
3395
|
-
*/
|
|
3396
3331
|
const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
|
|
3397
|
-
const changeFlowAmountDue = isChangeFlow
|
|
3398
|
-
? (() => {
|
|
3399
|
-
const rounded = Math.round(changeFlowAmountDueRaw * 100) / 100;
|
|
3400
|
-
return Math.abs(rounded) < 0.01 ? 0 : rounded;
|
|
3401
|
-
})()
|
|
3402
|
-
: changeFlowAmountDueRaw;
|
|
3332
|
+
const changeFlowAmountDue = isChangeFlow ? normalizeNearZeroOwed(changeFlowAmountDueRaw) : changeFlowAmountDueRaw;
|
|
3403
3333
|
|
|
3404
|
-
useEffect(() => {
|
|
3405
|
-
if (!isChangeFlow || !isLocalhostChangeBookingDebug()) return;
|
|
3406
|
-
const receiptTicketLines =
|
|
3407
|
-
originalReceipt?.lineItems?.filter((l) => (l.type || '').toUpperCase() === 'TICKET') ?? [];
|
|
3408
|
-
const ticketFloors: Record<string, number> = {};
|
|
3409
|
-
changeFlowTicketBookedUnitPriceByCategory.forEach((v, k) => {
|
|
3410
|
-
ticketFloors[k] = v;
|
|
3411
|
-
});
|
|
3412
|
-
const tag = '[viavia change-booking]';
|
|
3413
|
-
// Plain console.log only (no groupCollapsed — those are easy to miss / count as “hidden” in DevTools).
|
|
3414
|
-
console.log(tag, 'itinerary gates', {
|
|
3415
|
-
outboundMatch: changeFlowOutboundMatchesOriginalSelection,
|
|
3416
|
-
returnMatch: changeFlowReturnMatchesOriginalSelection,
|
|
3417
|
-
sameItinerary: changeFlowSameItineraryAsOriginalBooking,
|
|
3418
|
-
});
|
|
3419
|
-
console.log(tag, 'ids & times', {
|
|
3420
|
-
initialAvailabilityId: initialValues?.availabilityId ?? null,
|
|
3421
|
-
selectedAvailabilityId: selectedAvailability?.availabilityId ?? null,
|
|
3422
|
-
initialDateTime: initialValues?.dateTime ?? null,
|
|
3423
|
-
selectedDateTime: selectedAvailability?.dateTime ?? null,
|
|
3424
|
-
initialOptionId: initialValues?.productOptionId ?? null,
|
|
3425
|
-
selectedOptionId: selectedAvailability?.productOptionId ?? null,
|
|
3426
|
-
initialReturnId: initialValues?.returnAvailabilityId ?? null,
|
|
3427
|
-
selectedReturnId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3428
|
-
initialReturnDateTime: initialValues?.returnDateTime ?? null,
|
|
3429
|
-
selectedReturnDateTime: selectedReturnOption?.dateTime ?? null,
|
|
3430
|
-
});
|
|
3431
|
-
console.log(tag, 'selection flags', {
|
|
3432
|
-
hasChangeSelection,
|
|
3433
|
-
hasEffectiveChangeSelection,
|
|
3434
|
-
changeFlowNeedsServerPrice,
|
|
3435
|
-
dateChanged: changeSelectionDetails.dateChanged,
|
|
3436
|
-
returnChanged: changeSelectionDetails.returnChanged,
|
|
3437
|
-
optionChanged: changeSelectionDetails.optionChanged,
|
|
3438
|
-
countsChanged: changeSelectionDetails.countsChanged,
|
|
3439
|
-
});
|
|
3440
|
-
console.log(tag, 'promo', {
|
|
3441
|
-
promoFromApi: promoDiscountAmount,
|
|
3442
|
-
lockedPromoFallback: lockedPromoFallbackAmount,
|
|
3443
|
-
effectivePromo: effectivePromoDiscountAmount,
|
|
3444
|
-
});
|
|
3445
|
-
console.log(tag, 'receipt anchor (same itinerary)', {
|
|
3446
|
-
baselineCatalogTotal: changeFlowBaselineTotalPrice,
|
|
3447
|
-
catalogDeltaVsBaseline: totalPrice - changeFlowBaselineTotalPrice,
|
|
3448
|
-
anchoredTotalRaw: changeFlowReceiptAnchoredCatalogTotalRaw,
|
|
3449
|
-
resolvedProposedTotal: changeFlowProposedTotalResolved,
|
|
3450
|
-
});
|
|
3451
|
-
console.log(tag, 'subtotals (live vs protected)', {
|
|
3452
|
-
orderSummarySubtotal: subtotal,
|
|
3453
|
-
currentTicketSubtotal,
|
|
3454
|
-
changeFlowProtectedTicketSubtotal,
|
|
3455
|
-
currentFeeSubtotal,
|
|
3456
|
-
changeFlowProtectedFeeSubtotal,
|
|
3457
|
-
returnPriceAdjustment,
|
|
3458
|
-
changeFlowProtectedReturnAdjustment,
|
|
3459
|
-
effectiveSubtotal,
|
|
3460
|
-
tax: effectiveTax,
|
|
3461
|
-
totalPrice,
|
|
3462
|
-
changeFlowProposedTotal,
|
|
3463
|
-
changeFlowProposedTotalResolved,
|
|
3464
|
-
originalReceiptTotal: originalReceipt?.total ?? null,
|
|
3465
|
-
amountDue: changeFlowAmountDue,
|
|
3466
|
-
});
|
|
3467
|
-
console.log(tag, 'ticket price floors from receipt (empty => live catalog for tickets)', ticketFloors);
|
|
3468
|
-
console.log(tag, 'receipt TICKET lines', receiptTicketLines);
|
|
3469
|
-
}, [
|
|
3470
|
-
isChangeFlow,
|
|
3471
|
-
changeFlowOutboundMatchesOriginalSelection,
|
|
3472
|
-
changeFlowReturnMatchesOriginalSelection,
|
|
3473
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3474
|
-
initialValues?.availabilityId,
|
|
3475
|
-
initialValues?.dateTime,
|
|
3476
|
-
initialValues?.productOptionId,
|
|
3477
|
-
initialValues?.returnAvailabilityId,
|
|
3478
|
-
initialValues?.returnDateTime,
|
|
3479
|
-
selectedAvailability?.availabilityId,
|
|
3480
|
-
selectedAvailability?.dateTime,
|
|
3481
|
-
selectedAvailability?.productOptionId,
|
|
3482
|
-
selectedReturnOption?.returnAvailabilityId,
|
|
3483
|
-
selectedReturnOption?.dateTime,
|
|
3484
|
-
hasChangeSelection,
|
|
3485
|
-
hasEffectiveChangeSelection,
|
|
3486
|
-
changeFlowNeedsServerPrice,
|
|
3487
|
-
changeSelectionDetails.dateChanged,
|
|
3488
|
-
changeSelectionDetails.returnChanged,
|
|
3489
|
-
changeSelectionDetails.optionChanged,
|
|
3490
|
-
changeSelectionDetails.countsChanged,
|
|
3491
|
-
subtotal,
|
|
3492
|
-
currentTicketSubtotal,
|
|
3493
|
-
changeFlowProtectedTicketSubtotal,
|
|
3494
|
-
currentFeeSubtotal,
|
|
3495
|
-
changeFlowProtectedFeeSubtotal,
|
|
3496
|
-
returnPriceAdjustment,
|
|
3497
|
-
changeFlowProtectedReturnAdjustment,
|
|
3498
|
-
effectiveSubtotal,
|
|
3499
|
-
effectivePromoDiscountAmount,
|
|
3500
|
-
effectiveTax,
|
|
3501
|
-
totalPrice,
|
|
3502
|
-
changeFlowProposedTotal,
|
|
3503
|
-
originalReceipt?.total,
|
|
3504
|
-
originalReceipt?.lineItems,
|
|
3505
|
-
changeFlowAmountDue,
|
|
3506
|
-
changeFlowTicketBookedUnitPriceByCategory,
|
|
3507
|
-
changeFlowProposedTotalResolved,
|
|
3508
|
-
changeFlowBaselineTotalPrice,
|
|
3509
|
-
changeFlowReceiptAnchoredCatalogTotalRaw,
|
|
3510
|
-
promoDiscountAmount,
|
|
3511
|
-
lockedPromoFallbackAmount,
|
|
3512
|
-
]);
|
|
3513
3334
|
|
|
3514
3335
|
const changeCheckoutButtonLabel = (() => {
|
|
3515
3336
|
if (!isChangeFlow) return undefined;
|
|
@@ -3611,7 +3432,7 @@ export function BookingFlow({
|
|
|
3611
3432
|
dateChanged: changeSelectionDetails.dateChanged,
|
|
3612
3433
|
ticketsChanged: changeSelectionDetails.ticketsChanged,
|
|
3613
3434
|
hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
|
|
3614
|
-
selectionTotal: originalReceipt ?
|
|
3435
|
+
selectionTotal: originalReceipt ? changeFlowNewBookingTotal : totalPrice,
|
|
3615
3436
|
selectionCurrency: currency,
|
|
3616
3437
|
};
|
|
3617
3438
|
}, [
|
|
@@ -3625,7 +3446,7 @@ export function BookingFlow({
|
|
|
3625
3446
|
totalPrice,
|
|
3626
3447
|
currency,
|
|
3627
3448
|
originalReceipt,
|
|
3628
|
-
|
|
3449
|
+
changeFlowNewBookingTotal,
|
|
3629
3450
|
]);
|
|
3630
3451
|
|
|
3631
3452
|
useEffect(() => {
|
|
@@ -3668,7 +3489,7 @@ export function BookingFlow({
|
|
|
3668
3489
|
? displayChangeFlowTax
|
|
3669
3490
|
: effectiveTax
|
|
3670
3491
|
: 0,
|
|
3671
|
-
total: isChangeFlow && originalReceipt ?
|
|
3492
|
+
total: isChangeFlow && originalReceipt ? changeFlowNewBookingTotal : totalPrice,
|
|
3672
3493
|
currency,
|
|
3673
3494
|
});
|
|
3674
3495
|
}, [
|
|
@@ -3677,7 +3498,7 @@ export function BookingFlow({
|
|
|
3677
3498
|
totalQuantity,
|
|
3678
3499
|
effectiveSubtotal,
|
|
3679
3500
|
effectiveTax,
|
|
3680
|
-
|
|
3501
|
+
changeFlowNewBookingTotal,
|
|
3681
3502
|
displayChangeFlowSubtotal,
|
|
3682
3503
|
displayChangeFlowTax,
|
|
3683
3504
|
currency,
|
|
@@ -3739,7 +3560,7 @@ export function BookingFlow({
|
|
|
3739
3560
|
newPassengerCounts: bookingItems,
|
|
3740
3561
|
// Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
|
|
3741
3562
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3742
|
-
clientProposedTotal:
|
|
3563
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
3743
3564
|
capacitySeatCredit: {
|
|
3744
3565
|
enabled: true,
|
|
3745
3566
|
previousPassengerCount: changeFlowInitialTicketCount,
|
|
@@ -3791,7 +3612,7 @@ export function BookingFlow({
|
|
|
3791
3612
|
quantities,
|
|
3792
3613
|
addOnSelections,
|
|
3793
3614
|
changeFlowInitialTicketCount,
|
|
3794
|
-
|
|
3615
|
+
changeFlowNewBookingTotal,
|
|
3795
3616
|
totalPrice,
|
|
3796
3617
|
currency,
|
|
3797
3618
|
activeOptions,
|
|
@@ -3944,12 +3765,11 @@ export function BookingFlow({
|
|
|
3944
3765
|
|
|
3945
3766
|
const preferBooked =
|
|
3946
3767
|
isChangeFlow && initialReturnIdForSelect
|
|
3947
|
-
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect
|
|
3768
|
+
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
|
|
3948
3769
|
: undefined;
|
|
3949
3770
|
const preferByDateTime =
|
|
3950
3771
|
isChangeFlow && initialReturnDtForSelect && !preferBooked
|
|
3951
3772
|
? sorted.find((opt) => {
|
|
3952
|
-
if (opt.vacancies <= 0) return false;
|
|
3953
3773
|
try {
|
|
3954
3774
|
return (
|
|
3955
3775
|
parseAvailabilityDateTime(opt.dateTime).getTime() ===
|
|
@@ -4416,7 +4236,7 @@ export function BookingFlow({
|
|
|
4416
4236
|
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4417
4237
|
newPassengerCounts: bookingItems,
|
|
4418
4238
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
4419
|
-
clientProposedTotal:
|
|
4239
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
4420
4240
|
});
|
|
4421
4241
|
const canProceed = quote.canProceed !== false;
|
|
4422
4242
|
const quoteCurrency = (quote.currency || currency) as Currency;
|
|
@@ -4437,7 +4257,11 @@ export function BookingFlow({
|
|
|
4437
4257
|
if (!canProceed) {
|
|
4438
4258
|
throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
|
|
4439
4259
|
}
|
|
4440
|
-
const feChangeDue =
|
|
4260
|
+
const feChangeDue = changeFlowBalanceVsOriginal({
|
|
4261
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4262
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4263
|
+
audience: 'customer',
|
|
4264
|
+
});
|
|
4441
4265
|
const serverAmountDue =
|
|
4442
4266
|
quote.amountDueCents != null
|
|
4443
4267
|
? Math.max(0, quote.amountDueCents / 100)
|
|
@@ -4547,20 +4371,24 @@ export function BookingFlow({
|
|
|
4547
4371
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
4548
4372
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
4549
4373
|
const amountDueForCheckout = isCustomerSelfServeChange
|
|
4550
|
-
?
|
|
4374
|
+
? changeFlowBalanceVsOriginal({
|
|
4375
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4376
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4377
|
+
audience: 'customer',
|
|
4378
|
+
})
|
|
4551
4379
|
: totalPrice;
|
|
4552
4380
|
const lines = [
|
|
4553
|
-
...
|
|
4381
|
+
...ticketLineItemsForChangeFlowDisplay.map((line) => ({
|
|
4554
4382
|
label: line.category,
|
|
4555
4383
|
amount: line.itemTotal,
|
|
4556
4384
|
type: 'TICKET' as const,
|
|
4557
4385
|
quantity: line.qty,
|
|
4558
4386
|
})),
|
|
4559
|
-
...(
|
|
4387
|
+
...(checkoutReturnLineAmount !== 0
|
|
4560
4388
|
? [
|
|
4561
4389
|
{
|
|
4562
4390
|
label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
4563
|
-
amount:
|
|
4391
|
+
amount: checkoutReturnLineAmount,
|
|
4564
4392
|
type: 'RETURN_OPTION' as const,
|
|
4565
4393
|
quantity: totalQuantity,
|
|
4566
4394
|
},
|
|
@@ -4758,7 +4586,7 @@ export function BookingFlow({
|
|
|
4758
4586
|
availabilityProductOptionId,
|
|
4759
4587
|
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4760
4588
|
clientSecret: paymentIntent.clientSecret ?? '',
|
|
4761
|
-
ticketLinesForModal:
|
|
4589
|
+
ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4762
4590
|
const rate = pricing.find((r) => r.category === line.category);
|
|
4763
4591
|
const breakdown = getPriceBreakdown(
|
|
4764
4592
|
line.category,
|
|
@@ -4769,7 +4597,7 @@ export function BookingFlow({
|
|
|
4769
4597
|
return { line, breakdown };
|
|
4770
4598
|
}),
|
|
4771
4599
|
feeLineItems: feeLineItemsWithAddOns,
|
|
4772
|
-
returnPriceAdjustment,
|
|
4600
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4773
4601
|
cancellationPolicyFee,
|
|
4774
4602
|
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4775
4603
|
subtotal: effectiveSubtotal,
|
|
@@ -4785,7 +4613,7 @@ export function BookingFlow({
|
|
|
4785
4613
|
return;
|
|
4786
4614
|
}
|
|
4787
4615
|
|
|
4788
|
-
const ticketLinesForModal: CheckoutModalLineItem[] =
|
|
4616
|
+
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4789
4617
|
const rate = pricing.find((r) => r.category === line.category);
|
|
4790
4618
|
const breakdown = getPriceBreakdown(
|
|
4791
4619
|
line.category,
|
|
@@ -4821,7 +4649,7 @@ export function BookingFlow({
|
|
|
4821
4649
|
: undefined,
|
|
4822
4650
|
ticketLines: ticketLinesForModal,
|
|
4823
4651
|
feeLineItems: feeLineItemsWithAddOns,
|
|
4824
|
-
returnPriceAdjustment,
|
|
4652
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4825
4653
|
cancellationPolicyFee,
|
|
4826
4654
|
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4827
4655
|
subtotal: effectiveSubtotal,
|
|
@@ -5107,6 +4935,7 @@ export function BookingFlow({
|
|
|
5107
4935
|
availabilitiesByDate={availabilitiesByDate}
|
|
5108
4936
|
selectedDate={selectedDate}
|
|
5109
4937
|
syncVisibleWeekToSelectedDate={isChangeFlow}
|
|
4938
|
+
selectableSoldOutDate={changeFlowOriginalDate}
|
|
5110
4939
|
isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
|
|
5111
4940
|
onDateSelect={(date) => {
|
|
5112
4941
|
setSelectedDate(date);
|
|
@@ -5129,7 +4958,13 @@ export function BookingFlow({
|
|
|
5129
4958
|
currency={currency}
|
|
5130
4959
|
showCapacity={isAdmin}
|
|
5131
4960
|
extraDiscountPercent={calendarDiscountPercent}
|
|
5132
|
-
|
|
4961
|
+
capDiscountBadgesToBookingDate={
|
|
4962
|
+
isChangeFlow &&
|
|
4963
|
+
changeFlowApplyReceiptPaidFloors &&
|
|
4964
|
+
changeFlowTicketBookedUnitPriceByCategory.size > 0
|
|
4965
|
+
? changeFlowOriginalDate
|
|
4966
|
+
: null
|
|
4967
|
+
}
|
|
5133
4968
|
/>
|
|
5134
4969
|
</div>
|
|
5135
4970
|
</div>
|
|
@@ -5253,7 +5088,11 @@ export function BookingFlow({
|
|
|
5253
5088
|
t={t}
|
|
5254
5089
|
onQuantityChange={handleQuantityChange}
|
|
5255
5090
|
minimumQuantities={changeBookingMinimumQuantities}
|
|
5256
|
-
ticketUnitFloorByCategory={
|
|
5091
|
+
ticketUnitFloorByCategory={
|
|
5092
|
+
isChangeFlow && changeFlowApplyReceiptPaidFloors
|
|
5093
|
+
? changeFlowTicketBookedUnitPriceByCategory
|
|
5094
|
+
: undefined
|
|
5095
|
+
}
|
|
5257
5096
|
/>
|
|
5258
5097
|
)}
|
|
5259
5098
|
|
|
@@ -5271,6 +5110,7 @@ export function BookingFlow({
|
|
|
5271
5110
|
|
|
5272
5111
|
{/* Total and Checkout — shared PriceSummary component */}
|
|
5273
5112
|
{selectedAvailability && (
|
|
5113
|
+
<>
|
|
5274
5114
|
<CheckoutForm
|
|
5275
5115
|
priceSummaryLines={checkoutPriceSummaryLines}
|
|
5276
5116
|
totalPrice={changeFlowAmountDue}
|
|
@@ -5498,6 +5338,7 @@ export function BookingFlow({
|
|
|
5498
5338
|
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
5499
5339
|
}
|
|
5500
5340
|
/>
|
|
5341
|
+
</>
|
|
5501
5342
|
)}
|
|
5502
5343
|
</div>
|
|
5503
5344
|
</>
|