@ticketboothapp/booking 1.2.58 → 1.2.60
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/package.json +1 -1
- package/src/components/BookingDetails.ts +1 -0
- package/src/components/booking/BookingFlow.tsx +652 -194
- package/src/components/booking/Calendar.tsx +48 -16
- package/src/components/booking/ChangeBookingDialog.tsx +4 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +6 -0
- package/src/lib/booking/change-flow-pricing.ts +172 -0
- package/src/lib/booking/pricing.ts +27 -19
- package/src/lib/booking-api.ts +7 -0
|
@@ -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';
|
|
@@ -202,6 +213,92 @@ function findMergedAvailabilityForSelection(
|
|
|
202
213
|
return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
|
|
203
214
|
}
|
|
204
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
|
+
|
|
205
302
|
function findMergedReturnVacancies(
|
|
206
303
|
outbound: Availability | undefined,
|
|
207
304
|
selectedReturn: ReturnOption | null | undefined
|
|
@@ -285,12 +382,16 @@ interface BookingFlowProps {
|
|
|
285
382
|
dateTime?: string | null;
|
|
286
383
|
/** Inventory slot id from booking (when API persists it); strongest match for change flow. */
|
|
287
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;
|
|
288
387
|
productOptionId?: string | null;
|
|
289
388
|
pickupLocationId?: string | null;
|
|
290
389
|
/** Original booked return slot (round-trip), when API provides it. */
|
|
291
390
|
returnAvailabilityId?: string | null;
|
|
292
391
|
/** Fallback when only return datetime is on the booking payload. */
|
|
293
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;
|
|
294
395
|
bookingItems?: Array<{ category: string; count: number }> | null;
|
|
295
396
|
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
296
397
|
customer?: {
|
|
@@ -766,6 +867,18 @@ export function BookingFlow({
|
|
|
766
867
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
767
868
|
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
768
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]);
|
|
769
882
|
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
770
883
|
const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
|
|
771
884
|
|
|
@@ -1576,6 +1689,79 @@ export function BookingFlow({
|
|
|
1576
1689
|
[timesForSelectedDate],
|
|
1577
1690
|
);
|
|
1578
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
|
+
/** Minimum paid price / receipt anchoring applies for any option under this parent product. */
|
|
1758
|
+
const changeFlowApplyReceiptPaidFloors = useMemo(
|
|
1759
|
+
() =>
|
|
1760
|
+
isChangeFlow &&
|
|
1761
|
+
changeFlowBookingParentProductIdForFloors === product.productId.trim(),
|
|
1762
|
+
[isChangeFlow, changeFlowBookingParentProductIdForFloors, product.productId],
|
|
1763
|
+
);
|
|
1764
|
+
|
|
1579
1765
|
useEffect(() => {
|
|
1580
1766
|
if (hasAppliedInitialValuesRef.current || !initialValues) return;
|
|
1581
1767
|
const trimmedEmail = initialValues.customer?.email?.trim();
|
|
@@ -1616,7 +1802,7 @@ export function BookingFlow({
|
|
|
1616
1802
|
target,
|
|
1617
1803
|
companyTimezone,
|
|
1618
1804
|
initialValues.availabilityId ?? null,
|
|
1619
|
-
initialValues.productOptionId ?? null,
|
|
1805
|
+
changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
|
|
1620
1806
|
initialValues.bookingItems ?? null,
|
|
1621
1807
|
precomputedPricesByOption,
|
|
1622
1808
|
currency,
|
|
@@ -1632,6 +1818,7 @@ export function BookingFlow({
|
|
|
1632
1818
|
initialValues?.availabilityId,
|
|
1633
1819
|
initialValues?.productOptionId,
|
|
1634
1820
|
initialValues?.bookingItems,
|
|
1821
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1635
1822
|
selectedAvailability,
|
|
1636
1823
|
selectedDate,
|
|
1637
1824
|
timesForSelectedDate,
|
|
@@ -1779,7 +1966,61 @@ export function BookingFlow({
|
|
|
1779
1966
|
}
|
|
1780
1967
|
return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
|
|
1781
1968
|
}, [product.pickupLocations]);
|
|
1969
|
+
const changeFlowInitialTicketCountForSeatCredit = useMemo(() => {
|
|
1970
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return 0;
|
|
1971
|
+
return initialValues.bookingItems.reduce(
|
|
1972
|
+
(sum, item) => sum + Math.max(0, Number(item.count) || 0),
|
|
1973
|
+
0,
|
|
1974
|
+
);
|
|
1975
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
1782
1976
|
|
|
1977
|
+
const changeFlowSeatCreditForOutboundAvailability = useCallback(
|
|
1978
|
+
(availability: Availability): number => {
|
|
1979
|
+
if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1980
|
+
const initialAvailabilityId = initialValues?.availabilityId?.trim();
|
|
1981
|
+
const availabilityId = availability.availabilityId?.trim();
|
|
1982
|
+
if (initialAvailabilityId && availabilityId) {
|
|
1983
|
+
return initialAvailabilityId === availabilityId ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1984
|
+
}
|
|
1985
|
+
if (!initialValues?.dateTime) return 0;
|
|
1986
|
+
const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
|
|
1987
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
1988
|
+
if (selectedMs !== initialMs) return 0;
|
|
1989
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
|
|
1990
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
1991
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
1992
|
+
return selectedOpt === initialOpt ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1993
|
+
}
|
|
1994
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
1995
|
+
const legacySelected = availability.productOptionId?.trim() || null;
|
|
1996
|
+
if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCountForSeatCredit;
|
|
1997
|
+
return legacySelected === legacyInitial ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1998
|
+
},
|
|
1999
|
+
[
|
|
2000
|
+
isChangeFlow,
|
|
2001
|
+
changeFlowInitialTicketCountForSeatCredit,
|
|
2002
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2003
|
+
initialValues?.availabilityId,
|
|
2004
|
+
initialValues?.dateTime,
|
|
2005
|
+
initialValues?.productOptionId,
|
|
2006
|
+
],
|
|
2007
|
+
);
|
|
2008
|
+
const changeFlowSeatCreditForReturnAvailabilityId = useCallback(
|
|
2009
|
+
(returnAvailabilityId: string | null | undefined): number => {
|
|
2010
|
+
if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
2011
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
2012
|
+
const selectedReturnAvailabilityId = returnAvailabilityId?.trim() || null;
|
|
2013
|
+
if (!selectedReturnAvailabilityId) {
|
|
2014
|
+
return initialReturnAvailabilityId == null ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
2015
|
+
}
|
|
2016
|
+
if (!initialReturnAvailabilityId) return 0;
|
|
2017
|
+
return selectedReturnAvailabilityId === initialReturnAvailabilityId
|
|
2018
|
+
? changeFlowInitialTicketCountForSeatCredit
|
|
2019
|
+
: 0;
|
|
2020
|
+
},
|
|
2021
|
+
[isChangeFlow, changeFlowInitialTicketCountForSeatCredit, initialValues?.returnAvailabilityId],
|
|
2022
|
+
);
|
|
2023
|
+
|
|
1783
2024
|
// Calculate pickup times based on availability times + pickup location offset
|
|
1784
2025
|
interface PickupTimeInfo extends Availability {
|
|
1785
2026
|
pickupTime: string;
|
|
@@ -1802,6 +2043,8 @@ export function BookingFlow({
|
|
|
1802
2043
|
return timesForSelectedDate.map(avail => {
|
|
1803
2044
|
// Parse the dateTime (which should already be in company timezone from backend)
|
|
1804
2045
|
const availabilityTime = parseISO(avail.dateTime);
|
|
2046
|
+
const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
|
|
2047
|
+
const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
|
|
1805
2048
|
|
|
1806
2049
|
// Only apply offset if it's set and > 0 and location is selected
|
|
1807
2050
|
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
@@ -1822,13 +2065,22 @@ export function BookingFlow({
|
|
|
1822
2065
|
|
|
1823
2066
|
return {
|
|
1824
2067
|
...avail,
|
|
2068
|
+
vacancies: adjustedVacancies,
|
|
1825
2069
|
pickupTime: pickupTime.toISOString(),
|
|
1826
2070
|
displayTime,
|
|
1827
2071
|
originalTime,
|
|
1828
2072
|
displayTimeRange,
|
|
1829
2073
|
};
|
|
1830
2074
|
});
|
|
1831
|
-
}, [
|
|
2075
|
+
}, [
|
|
2076
|
+
selectedDate,
|
|
2077
|
+
selectedPickupLocation,
|
|
2078
|
+
timesForSelectedDate,
|
|
2079
|
+
pickupLocationSkipped,
|
|
2080
|
+
maxTimeOffsetMinutes,
|
|
2081
|
+
companyTimezone,
|
|
2082
|
+
changeFlowSeatCreditForOutboundAvailability,
|
|
2083
|
+
]);
|
|
1832
2084
|
|
|
1833
2085
|
// Check if any pickup time has "most popular" tag (memoized for performance)
|
|
1834
2086
|
const hasAnyMostPopular = useMemo(() => {
|
|
@@ -2095,7 +2347,11 @@ export function BookingFlow({
|
|
|
2095
2347
|
return floors;
|
|
2096
2348
|
}, [isChangeFlow, originalReceipt?.lineItems]);
|
|
2097
2349
|
|
|
2098
|
-
|
|
2350
|
+
/**
|
|
2351
|
+
* When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
|
|
2352
|
+
* so catalog "free" return slots still show and price at the original return value in change flow.
|
|
2353
|
+
*/
|
|
2354
|
+
const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
|
|
2099
2355
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return null;
|
|
2100
2356
|
const fallbackBookedQty =
|
|
2101
2357
|
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
@@ -2103,7 +2359,7 @@ export function BookingFlow({
|
|
|
2103
2359
|
let totalQty = 0;
|
|
2104
2360
|
for (const line of originalReceipt.lineItems) {
|
|
2105
2361
|
const type = (line.type || '').trim().toUpperCase();
|
|
2106
|
-
if (type !== 'RETURN_OPTION') continue;
|
|
2362
|
+
if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
|
|
2107
2363
|
const amount = Number(line.amount ?? 0);
|
|
2108
2364
|
const qtyRaw = Number(line.quantity ?? 0);
|
|
2109
2365
|
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
@@ -2114,6 +2370,15 @@ export function BookingFlow({
|
|
|
2114
2370
|
if (totalAmount <= 0 || totalQty <= 0) return null;
|
|
2115
2371
|
return totalAmount / totalQty;
|
|
2116
2372
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2373
|
+
|
|
2374
|
+
const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
|
|
2375
|
+
if (!isChangeFlow) return null;
|
|
2376
|
+
const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
|
|
2377
|
+
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
|
|
2378
|
+
const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
|
|
2379
|
+
if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
|
|
2380
|
+
return null;
|
|
2381
|
+
}, [isChangeFlow, initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
|
|
2117
2382
|
const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
|
|
2118
2383
|
const feeUnitByLabel = new Map<string, number>();
|
|
2119
2384
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
|
|
@@ -2134,27 +2399,137 @@ export function BookingFlow({
|
|
|
2134
2399
|
return feeUnitByLabel;
|
|
2135
2400
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2136
2401
|
|
|
2402
|
+
const changeFlowInitialTicketQtyByCategory = useMemo(() => {
|
|
2403
|
+
const qtyByCategory = new Map<string, number>();
|
|
2404
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
|
|
2405
|
+
for (const item of initialValues.bookingItems) {
|
|
2406
|
+
const category = item.category?.trim().toUpperCase();
|
|
2407
|
+
if (!category) continue;
|
|
2408
|
+
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2409
|
+
}
|
|
2410
|
+
return qtyByCategory;
|
|
2411
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
2412
|
+
const changeFlowInitialTicketCount = useMemo(() => {
|
|
2413
|
+
let sum = 0;
|
|
2414
|
+
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2415
|
+
return sum;
|
|
2416
|
+
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2417
|
+
const changeFlowOutboundMatchesOriginalSelection = useMemo(() => {
|
|
2418
|
+
if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
|
|
2419
|
+
const initialAvailabilityId = initialValues.availabilityId?.trim();
|
|
2420
|
+
const selectedAvailabilityId = selectedAvailability.availabilityId?.trim();
|
|
2421
|
+
const idsMatch =
|
|
2422
|
+
Boolean(initialAvailabilityId && selectedAvailabilityId) &&
|
|
2423
|
+
initialAvailabilityId === selectedAvailabilityId;
|
|
2424
|
+
if (idsMatch) return true;
|
|
2425
|
+
// Same wall time + same product option (ids may rotate on refresh).
|
|
2426
|
+
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2427
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2428
|
+
if (selectedMs !== initialMs) return false;
|
|
2429
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
2430
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
2431
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
2432
|
+
return selectedOpt === initialOpt;
|
|
2433
|
+
}
|
|
2434
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
2435
|
+
const legacySelected = selectedAvailability.productOptionId?.trim() || null;
|
|
2436
|
+
return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
|
|
2437
|
+
}, [
|
|
2438
|
+
isChangeFlow,
|
|
2439
|
+
selectedAvailability,
|
|
2440
|
+
initialValues?.availabilityId,
|
|
2441
|
+
initialValues?.dateTime,
|
|
2442
|
+
initialValues?.productOptionId,
|
|
2443
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2444
|
+
]);
|
|
2445
|
+
const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
|
|
2446
|
+
if (!isChangeFlow) return false;
|
|
2447
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
2448
|
+
const selectedReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
|
|
2449
|
+
const initialReturnDt = initialValues?.returnDateTime?.trim() || null;
|
|
2450
|
+
const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
|
|
2451
|
+
|
|
2452
|
+
if (!selectedReturnAvailabilityId) {
|
|
2453
|
+
return initialReturnAvailabilityId == null;
|
|
2454
|
+
}
|
|
2455
|
+
if (initialReturnAvailabilityId && selectedReturnAvailabilityId) {
|
|
2456
|
+
if (initialReturnAvailabilityId === selectedReturnAvailabilityId) return true;
|
|
2457
|
+
if (initialReturnDt && selectedReturnDt) {
|
|
2458
|
+
return (
|
|
2459
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2460
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
return false;
|
|
2464
|
+
}
|
|
2465
|
+
// Bookings often store returnDateTime without returnAvailabilityId; still the same return if wall times match.
|
|
2466
|
+
if (!initialReturnAvailabilityId && initialReturnDt && selectedReturnDt) {
|
|
2467
|
+
return (
|
|
2468
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2469
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
return false;
|
|
2473
|
+
}, [
|
|
2474
|
+
isChangeFlow,
|
|
2475
|
+
initialValues?.returnAvailabilityId,
|
|
2476
|
+
initialValues?.returnDateTime,
|
|
2477
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
2478
|
+
selectedReturnOption?.dateTime,
|
|
2479
|
+
]);
|
|
2480
|
+
/** Same outbound + return as original booking: incremental seats use live pricing; receipt floors apply only after itinerary changes. */
|
|
2481
|
+
const changeFlowSameItineraryAsOriginalBooking = useMemo(
|
|
2482
|
+
() =>
|
|
2483
|
+
isChangeFlow &&
|
|
2484
|
+
changeFlowOutboundMatchesOriginalSelection &&
|
|
2485
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2486
|
+
[isChangeFlow, changeFlowOutboundMatchesOriginalSelection, changeFlowReturnMatchesOriginalSelection],
|
|
2487
|
+
);
|
|
2488
|
+
|
|
2137
2489
|
const returnOptionsWithFloor = useMemo(() => {
|
|
2138
2490
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
2139
|
-
if (!isChangeFlow
|
|
2491
|
+
if (!isChangeFlow && effectiveChangeFlowReturnUnitFloorPerPerson == null) return options;
|
|
2140
2492
|
return options.map((opt) => {
|
|
2493
|
+
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2494
|
+
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2141
2495
|
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2142
|
-
const
|
|
2143
|
-
|
|
2496
|
+
const applyReturnFloor =
|
|
2497
|
+
effectiveChangeFlowReturnUnitFloorPerPerson != null &&
|
|
2498
|
+
(
|
|
2499
|
+
// Public self-serve change flow should always display the paid return floor on cards.
|
|
2500
|
+
isCustomerSelfServeChange ||
|
|
2501
|
+
// For non-public paths, only apply floor after itinerary changes.
|
|
2502
|
+
!isChangeFlow ||
|
|
2503
|
+
!changeFlowSameItineraryAsOriginalBooking
|
|
2504
|
+
);
|
|
2505
|
+
const flooredPerPerson = applyReturnFloor
|
|
2506
|
+
? Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson!)
|
|
2507
|
+
: rawPerPerson;
|
|
2508
|
+
if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
|
|
2144
2509
|
return {
|
|
2145
2510
|
...opt,
|
|
2511
|
+
vacancies: adjustedVacancies,
|
|
2146
2512
|
priceAdjustmentByCurrency: {
|
|
2147
2513
|
...(opt.priceAdjustmentByCurrency ?? {}),
|
|
2148
2514
|
[currency]: flooredPerPerson,
|
|
2149
2515
|
},
|
|
2150
2516
|
};
|
|
2151
2517
|
});
|
|
2152
|
-
}, [
|
|
2518
|
+
}, [
|
|
2519
|
+
selectedAvailability?.returnOptions,
|
|
2520
|
+
isChangeFlow,
|
|
2521
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2522
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2523
|
+
isCustomerSelfServeChange,
|
|
2524
|
+
currency,
|
|
2525
|
+
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2526
|
+
]);
|
|
2153
2527
|
|
|
2154
2528
|
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2155
|
-
if (!selectedReturnOption || !isChangeFlow ||
|
|
2529
|
+
if (!selectedReturnOption || !isChangeFlow || effectiveChangeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
|
|
2530
|
+
if (!isCustomerSelfServeChange && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
|
|
2156
2531
|
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2157
|
-
const flooredPerPerson = Math.max(rawPerPerson,
|
|
2532
|
+
const flooredPerPerson = Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson);
|
|
2158
2533
|
if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
|
|
2159
2534
|
return {
|
|
2160
2535
|
...selectedReturnOption,
|
|
@@ -2163,74 +2538,60 @@ export function BookingFlow({
|
|
|
2163
2538
|
[currency]: flooredPerPerson,
|
|
2164
2539
|
},
|
|
2165
2540
|
};
|
|
2166
|
-
}, [
|
|
2541
|
+
}, [
|
|
2542
|
+
selectedReturnOption,
|
|
2543
|
+
isChangeFlow,
|
|
2544
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2545
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2546
|
+
isCustomerSelfServeChange,
|
|
2547
|
+
currency,
|
|
2548
|
+
]);
|
|
2549
|
+
|
|
2550
|
+
const returnOptionForOrderSummary = useMemo(() => {
|
|
2551
|
+
// Same itinerary + dashboard change: keep raw return option (floor only after itinerary changes).
|
|
2552
|
+
// Public self-serve: always apply API return floor on options so totals match paid return.
|
|
2553
|
+
if (
|
|
2554
|
+
isChangeFlow &&
|
|
2555
|
+
changeFlowSameItineraryAsOriginalBooking &&
|
|
2556
|
+
!isCustomerSelfServeChange
|
|
2557
|
+
) {
|
|
2558
|
+
return selectedReturnOption ?? null;
|
|
2559
|
+
}
|
|
2560
|
+
return selectedReturnOptionWithFloor;
|
|
2561
|
+
}, [
|
|
2562
|
+
isChangeFlow,
|
|
2563
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2564
|
+
isCustomerSelfServeChange,
|
|
2565
|
+
selectedReturnOption,
|
|
2566
|
+
selectedReturnOptionWithFloor,
|
|
2567
|
+
]);
|
|
2568
|
+
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2569
|
+
if (!selectedAvailability) return 0;
|
|
2570
|
+
const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
|
|
2571
|
+
return Math.max(0, selectedAvailability.vacancies ?? 0) + seatCredit;
|
|
2572
|
+
}, [selectedAvailability, changeFlowSeatCreditForOutboundAvailability]);
|
|
2573
|
+
const effectiveSelectedReturnVacancies = useMemo(() => {
|
|
2574
|
+
if (!selectedReturnOption) return null;
|
|
2575
|
+
const seatCredit = changeFlowSeatCreditForReturnAvailabilityId(
|
|
2576
|
+
selectedReturnOption.returnAvailabilityId
|
|
2577
|
+
);
|
|
2578
|
+
return Math.max(0, selectedReturnOption.vacancies ?? 0) + seatCredit;
|
|
2579
|
+
}, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
|
|
2167
2580
|
|
|
2168
2581
|
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2169
|
-
const pricing = useMemo(
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
2176
|
-
const getBaseInDisplayCurrency = (category: string) => {
|
|
2177
|
-
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
2178
|
-
if (fromPrecomputed != null) return fromPrecomputed;
|
|
2179
|
-
return 0;
|
|
2180
|
-
};
|
|
2181
|
-
const buildRate = (
|
|
2182
|
-
category: string,
|
|
2183
|
-
backendPriceCAD: number,
|
|
2184
|
-
backendInDisplayCurrency: number,
|
|
2185
|
-
baseInDisplayCurrency: number,
|
|
2186
|
-
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>
|
|
2187
|
-
) => {
|
|
2188
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
2189
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
2190
|
-
const breakdown = computePriceBreakdown(
|
|
2191
|
-
pricingConfig,
|
|
2582
|
+
const pricing = useMemo(
|
|
2583
|
+
() =>
|
|
2584
|
+
buildPricingFromAvailability(
|
|
2585
|
+
selectedAvailability,
|
|
2586
|
+
activeOptions,
|
|
2587
|
+
precomputedPricesByOption,
|
|
2192
2588
|
currency,
|
|
2193
|
-
|
|
2194
|
-
basePriceCAD,
|
|
2589
|
+
pricingConfig,
|
|
2195
2590
|
hasFees,
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
);
|
|
2201
|
-
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
2202
|
-
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
2203
|
-
};
|
|
2204
|
-
return selectedAvailability.rates?.map(rate => {
|
|
2205
|
-
const backendPriceCAD = rate.price ?? 0;
|
|
2206
|
-
const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
2207
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
2208
|
-
const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
|
|
2209
|
-
return {
|
|
2210
|
-
category: rate.category,
|
|
2211
|
-
rateId: rate.rateId || rate.category,
|
|
2212
|
-
available: rate.available,
|
|
2213
|
-
price: built.price,
|
|
2214
|
-
priceCAD: built.priceCAD,
|
|
2215
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2216
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
2217
|
-
};
|
|
2218
|
-
}) || selectedAvailability.pricesByCategory?.retailPrices?.map(p => {
|
|
2219
|
-
const priceCADFromApi = p.price / 100;
|
|
2220
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
2221
|
-
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
2222
|
-
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
2223
|
-
return {
|
|
2224
|
-
category: p.category,
|
|
2225
|
-
rateId: p.category,
|
|
2226
|
-
available: selectedAvailability.vacancies,
|
|
2227
|
-
price: built.price,
|
|
2228
|
-
priceCAD: built.priceCAD,
|
|
2229
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2230
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
2231
|
-
};
|
|
2232
|
-
}) || [];
|
|
2233
|
-
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
|
|
2591
|
+
isSimplifiedPricingView,
|
|
2592
|
+
),
|
|
2593
|
+
[selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
|
|
2594
|
+
);
|
|
2234
2595
|
|
|
2235
2596
|
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2236
2597
|
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
@@ -2257,56 +2618,38 @@ export function BookingFlow({
|
|
|
2257
2618
|
computeOrderSummary(
|
|
2258
2619
|
quantities,
|
|
2259
2620
|
pricing,
|
|
2260
|
-
|
|
2621
|
+
returnOptionForOrderSummary,
|
|
2261
2622
|
pricingConfig ?? null,
|
|
2262
2623
|
currency,
|
|
2263
2624
|
hasFees,
|
|
2264
2625
|
cancellationPolicyId
|
|
2265
2626
|
),
|
|
2266
|
-
[quantities, pricing,
|
|
2627
|
+
[quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2267
2628
|
);
|
|
2629
|
+
|
|
2268
2630
|
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2269
|
-
const changeFlowInitialTicketQtyByCategory = useMemo(() => {
|
|
2270
|
-
const qtyByCategory = new Map<string, number>();
|
|
2271
|
-
if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
|
|
2272
|
-
for (const item of initialValues.bookingItems) {
|
|
2273
|
-
const category = item.category?.trim().toUpperCase();
|
|
2274
|
-
if (!category) continue;
|
|
2275
|
-
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2276
|
-
}
|
|
2277
|
-
return qtyByCategory;
|
|
2278
|
-
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
2279
|
-
const changeFlowPreserveBookedTicketPricing = useMemo(() => {
|
|
2280
|
-
if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
|
|
2281
|
-
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2282
|
-
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2283
|
-
if (selectedMs !== initialMs) return false;
|
|
2284
|
-
const selectedOptionId = selectedAvailability.productOptionId?.trim() || null;
|
|
2285
|
-
const initialOptionId = initialValues.productOptionId?.trim() || null;
|
|
2286
|
-
// If option IDs are missing, still protect pricing when date/time is unchanged.
|
|
2287
|
-
if (!selectedOptionId || !initialOptionId) return true;
|
|
2288
|
-
return selectedOptionId === initialOptionId;
|
|
2289
|
-
}, [isChangeFlow, selectedAvailability, initialValues?.dateTime, initialValues?.productOptionId]);
|
|
2290
2631
|
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2291
2632
|
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2292
2633
|
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2293
2634
|
0,
|
|
2294
2635
|
);
|
|
2295
|
-
if (!
|
|
2636
|
+
if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
|
|
2296
2637
|
return ticketLineItems.reduce((sum, line) => {
|
|
2297
2638
|
const category = line.category?.trim().toUpperCase();
|
|
2298
2639
|
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2299
|
-
const liveUnitPrice = qty > 0 ? line.itemTotal / qty : 0;
|
|
2300
2640
|
if (!category || qty <= 0) return sum;
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2641
|
+
return (
|
|
2642
|
+
sum +
|
|
2643
|
+
changeFlowTicketLineTotalWithReceiptFloor({
|
|
2644
|
+
qty,
|
|
2645
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2646
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2647
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2648
|
+
})
|
|
2649
|
+
);
|
|
2307
2650
|
}, 0);
|
|
2308
2651
|
}, [
|
|
2309
|
-
|
|
2652
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2310
2653
|
ticketLineItems,
|
|
2311
2654
|
changeFlowTicketBookedUnitPriceByCategory,
|
|
2312
2655
|
changeFlowInitialTicketQtyByCategory,
|
|
@@ -2314,10 +2657,25 @@ export function BookingFlow({
|
|
|
2314
2657
|
/** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
|
|
2315
2658
|
const effectivePartySizeCap = useMemo(() => {
|
|
2316
2659
|
if (!selectedAvailability) return 0;
|
|
2317
|
-
const
|
|
2660
|
+
const outboundSeatCredit =
|
|
2661
|
+
changeFlowOutboundMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2662
|
+
? changeFlowInitialTicketCount
|
|
2663
|
+
: 0;
|
|
2664
|
+
const outbound = Math.max(0, selectedAvailability.vacancies ?? 0) + outboundSeatCredit;
|
|
2318
2665
|
if (selectedReturnOption == null) return outbound;
|
|
2319
|
-
|
|
2320
|
-
|
|
2666
|
+
const returnSeatCredit =
|
|
2667
|
+
changeFlowReturnMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2668
|
+
? changeFlowInitialTicketCount
|
|
2669
|
+
: 0;
|
|
2670
|
+
const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0) + returnSeatCredit;
|
|
2671
|
+
return Math.min(outbound, returnCap);
|
|
2672
|
+
}, [
|
|
2673
|
+
selectedAvailability,
|
|
2674
|
+
selectedReturnOption,
|
|
2675
|
+
changeFlowInitialTicketCount,
|
|
2676
|
+
changeFlowOutboundMatchesOriginalSelection,
|
|
2677
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2678
|
+
]);
|
|
2321
2679
|
|
|
2322
2680
|
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
2323
2681
|
/** Label for display when policy may be forced by promo (not in pricingConfig list). */
|
|
@@ -2378,50 +2736,104 @@ export function BookingFlow({
|
|
|
2378
2736
|
() => ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0),
|
|
2379
2737
|
[ticketLineItems],
|
|
2380
2738
|
);
|
|
2381
|
-
const changeFlowInitialTotalTicketQty =
|
|
2382
|
-
let sum = 0;
|
|
2383
|
-
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2384
|
-
return sum;
|
|
2385
|
-
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2739
|
+
const changeFlowInitialTotalTicketQty = changeFlowInitialTicketCount;
|
|
2386
2740
|
const currentFeeSubtotal = useMemo(
|
|
2387
2741
|
() => feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0),
|
|
2388
2742
|
[feeLineItems],
|
|
2389
2743
|
);
|
|
2390
2744
|
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2391
|
-
if (!
|
|
2745
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2746
|
+
const initialParty = changeFlowInitialTicketCount;
|
|
2392
2747
|
return feeLineItems.reduce((sum, line) => {
|
|
2393
2748
|
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2394
|
-
const
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2749
|
+
const bookedFeePerPerson = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : undefined;
|
|
2750
|
+
return (
|
|
2751
|
+
sum +
|
|
2752
|
+
changeFlowFeeLineTotalWithReceiptFloor({
|
|
2753
|
+
totalQuantity,
|
|
2754
|
+
initialTicketCount: initialParty,
|
|
2755
|
+
bookedFeePerPerson,
|
|
2756
|
+
liveFeeLineTotal: Number(line.totalAmount) || 0,
|
|
2757
|
+
})
|
|
2758
|
+
);
|
|
2400
2759
|
}, 0);
|
|
2401
2760
|
}, [
|
|
2402
|
-
|
|
2761
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2403
2762
|
totalQuantity,
|
|
2404
2763
|
currentFeeSubtotal,
|
|
2405
2764
|
feeLineItems,
|
|
2406
2765
|
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2407
|
-
|
|
2766
|
+
changeFlowInitialTicketCount,
|
|
2408
2767
|
]);
|
|
2768
|
+
/** Catalog (unfloored) return price per person — same slot as [selectedReturnOption] on the raw availability list. */
|
|
2769
|
+
const returnOptionCatalogPerPerson = useMemo(() => {
|
|
2770
|
+
if (!selectedReturnOption?.returnAvailabilityId || !selectedAvailability?.returnOptions?.length) {
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
const raw = selectedAvailability.returnOptions.find(
|
|
2774
|
+
(o) => o.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
2775
|
+
);
|
|
2776
|
+
const v = raw?.priceAdjustmentByCurrency?.[currency];
|
|
2777
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
2778
|
+
}, [selectedReturnOption?.returnAvailabilityId, selectedAvailability?.returnOptions, currency]);
|
|
2779
|
+
|
|
2409
2780
|
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2410
|
-
if (!
|
|
2411
|
-
if (
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2414
|
-
const
|
|
2415
|
-
|
|
2781
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return returnPriceAdjustment;
|
|
2782
|
+
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2783
|
+
const livePerPerson =
|
|
2784
|
+
returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
|
|
2785
|
+
const perPerson = changeFlowReturnPerPersonWithReceiptFloor({
|
|
2786
|
+
livePerPerson,
|
|
2787
|
+
receiptFloorPerPerson: effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2788
|
+
});
|
|
2789
|
+
return totalQuantity * perPerson;
|
|
2416
2790
|
}, [
|
|
2417
|
-
|
|
2791
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2418
2792
|
totalQuantity,
|
|
2419
2793
|
returnPriceAdjustment,
|
|
2420
|
-
|
|
2421
|
-
|
|
2794
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2795
|
+
selectedReturnOption,
|
|
2796
|
+
returnOptionCatalogPerPerson,
|
|
2797
|
+
currency,
|
|
2798
|
+
]);
|
|
2799
|
+
|
|
2800
|
+
/** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
|
|
2801
|
+
const checkoutReturnLineAmount = useMemo(() => {
|
|
2802
|
+
if (isChangeFlow && changeFlowApplyReceiptPaidFloors) {
|
|
2803
|
+
return changeFlowProtectedReturnAdjustment;
|
|
2804
|
+
}
|
|
2805
|
+
return returnPriceAdjustment;
|
|
2806
|
+
}, [
|
|
2807
|
+
isChangeFlow,
|
|
2808
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2809
|
+
changeFlowProtectedReturnAdjustment,
|
|
2810
|
+
returnPriceAdjustment,
|
|
2811
|
+
]);
|
|
2812
|
+
|
|
2813
|
+
/** Ticket lines with receipt floors applied for breakdown/modal (matches protected ticket subtotal). */
|
|
2814
|
+
const ticketLineItemsForChangeFlowDisplay = useMemo(() => {
|
|
2815
|
+
if (!changeFlowApplyReceiptPaidFloors) return ticketLineItems;
|
|
2816
|
+
return ticketLineItems.map((line) => {
|
|
2817
|
+
const category = line.category?.trim().toUpperCase();
|
|
2818
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2819
|
+
if (!category || qty <= 0) return line;
|
|
2820
|
+
const newTotal = changeFlowTicketLineTotalWithReceiptFloor({
|
|
2821
|
+
qty,
|
|
2822
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2823
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2824
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2825
|
+
});
|
|
2826
|
+
return { ...line, itemTotal: newTotal };
|
|
2827
|
+
});
|
|
2828
|
+
}, [
|
|
2829
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2830
|
+
ticketLineItems,
|
|
2831
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2832
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2422
2833
|
]);
|
|
2834
|
+
|
|
2423
2835
|
const effectiveSubtotalBeforeAddOns =
|
|
2424
|
-
isChangeFlow &&
|
|
2836
|
+
isChangeFlow && changeFlowApplyReceiptPaidFloors
|
|
2425
2837
|
? subtotal -
|
|
2426
2838
|
currentTicketSubtotal -
|
|
2427
2839
|
currentFeeSubtotal -
|
|
@@ -2488,8 +2900,11 @@ export function BookingFlow({
|
|
|
2488
2900
|
|
|
2489
2901
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2490
2902
|
if (!selectedAvailability) return [];
|
|
2903
|
+
const returnLineAmount = checkoutReturnLineAmount;
|
|
2904
|
+
const showReturnLine =
|
|
2905
|
+
Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
|
|
2491
2906
|
return [
|
|
2492
|
-
...
|
|
2907
|
+
...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
|
|
2493
2908
|
const rate = pricing.find((r) => r.category === line.category);
|
|
2494
2909
|
const breakdown = getPriceBreakdown(
|
|
2495
2910
|
line.category,
|
|
@@ -2512,12 +2927,12 @@ export function BookingFlow({
|
|
|
2512
2927
|
breakdown,
|
|
2513
2928
|
};
|
|
2514
2929
|
}),
|
|
2515
|
-
...(
|
|
2930
|
+
...(showReturnLine
|
|
2516
2931
|
? [
|
|
2517
2932
|
{
|
|
2518
2933
|
kind: 'line' as const,
|
|
2519
2934
|
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
2520
|
-
amount:
|
|
2935
|
+
amount: returnLineAmount,
|
|
2521
2936
|
type: 'return',
|
|
2522
2937
|
},
|
|
2523
2938
|
]
|
|
@@ -2572,11 +2987,11 @@ export function BookingFlow({
|
|
|
2572
2987
|
];
|
|
2573
2988
|
}, [
|
|
2574
2989
|
selectedAvailability,
|
|
2575
|
-
|
|
2990
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
2576
2991
|
pricing,
|
|
2577
2992
|
getPriceBreakdown,
|
|
2578
2993
|
selectedReturnOption,
|
|
2579
|
-
|
|
2994
|
+
checkoutReturnLineAmount,
|
|
2580
2995
|
totalQuantity,
|
|
2581
2996
|
t,
|
|
2582
2997
|
cancellationPolicyFee,
|
|
@@ -2713,9 +3128,13 @@ export function BookingFlow({
|
|
|
2713
3128
|
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
2714
3129
|
: taxOnSubtotal;
|
|
2715
3130
|
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
3131
|
+
/** Change-flow product rules: `lib/booking/change-flow-pricing.ts`. */
|
|
3132
|
+
const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
|
|
3133
|
+
isChangeFlow,
|
|
3134
|
+
cartTotal: totalPrice,
|
|
3135
|
+
originalReceiptTotal: originalReceipt?.total,
|
|
3136
|
+
});
|
|
3137
|
+
|
|
2719
3138
|
const changeSelectionDetails = useMemo(() => {
|
|
2720
3139
|
if (!isChangeFlow || !initialValues) {
|
|
2721
3140
|
return {
|
|
@@ -2747,11 +3166,12 @@ export function BookingFlow({
|
|
|
2747
3166
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2748
3167
|
// Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
|
|
2749
3168
|
const dateChanged = initialMs != null && initialMs !== selectedMs;
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
3169
|
+
const initialOpt =
|
|
3170
|
+
changeFlowResolvedInitialProductOptionId ??
|
|
3171
|
+
(initialValues.productOptionId?.trim() || null);
|
|
3172
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
2753
3173
|
const optionChanged = Boolean(
|
|
2754
|
-
|
|
3174
|
+
selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
|
|
2755
3175
|
);
|
|
2756
3176
|
const normalizePickupId = (value: string | null | undefined) => {
|
|
2757
3177
|
const trimmed = value?.trim();
|
|
@@ -2851,6 +3271,7 @@ export function BookingFlow({
|
|
|
2851
3271
|
}, [
|
|
2852
3272
|
isChangeFlow,
|
|
2853
3273
|
initialValues,
|
|
3274
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2854
3275
|
selectedAvailability,
|
|
2855
3276
|
selectedReturnOption,
|
|
2856
3277
|
implicitReturnBaselineId,
|
|
@@ -2885,15 +3306,6 @@ export function BookingFlow({
|
|
|
2885
3306
|
showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
|
|
2886
3307
|
? providerPricingUi.totalsPreview
|
|
2887
3308
|
: null;
|
|
2888
|
-
const displayChangeFlowProposedTotal = providerTotalsPreview
|
|
2889
|
-
? providerTotalsPreview.totalAmount
|
|
2890
|
-
: changeFlowProposedTotal;
|
|
2891
|
-
const displayChangeFlowSubtotal = providerTotalsPreview
|
|
2892
|
-
? providerTotalsPreview.subtotalBeforeTax
|
|
2893
|
-
: effectiveSubtotal;
|
|
2894
|
-
const displayChangeFlowTax = providerTotalsPreview
|
|
2895
|
-
? providerTotalsPreview.taxAmount
|
|
2896
|
-
: effectiveTax;
|
|
2897
3309
|
const providerHasEditedLineOverrides =
|
|
2898
3310
|
isProviderDashboardChange &&
|
|
2899
3311
|
providerQuotedLines.some((line) => {
|
|
@@ -2921,23 +3333,29 @@ export function BookingFlow({
|
|
|
2921
3333
|
const hasEffectiveChangeSelection =
|
|
2922
3334
|
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
2923
3335
|
|
|
3336
|
+
const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
|
|
3337
|
+
providerPreview: providerTotalsPreview,
|
|
3338
|
+
fromCart: {
|
|
3339
|
+
total: changeFlowNewBookingTotal,
|
|
3340
|
+
subtotal: effectiveSubtotal,
|
|
3341
|
+
tax: effectiveTax,
|
|
3342
|
+
},
|
|
3343
|
+
});
|
|
3344
|
+
const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
|
|
3345
|
+
const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
|
|
3346
|
+
const displayChangeFlowTax = displayedChangeAmounts.tax;
|
|
3347
|
+
|
|
2924
3348
|
const changeFlowClientEstimateDue = originalReceipt
|
|
2925
|
-
? (
|
|
2926
|
-
|
|
2927
|
-
:
|
|
3349
|
+
? changeFlowBalanceVsOriginal({
|
|
3350
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
3351
|
+
originalReceiptTotal: originalReceipt.total,
|
|
3352
|
+
audience: isProviderDashboardChange ? 'provider' : 'customer',
|
|
3353
|
+
})
|
|
2928
3354
|
: totalPrice;
|
|
2929
3355
|
|
|
2930
|
-
/**
|
|
2931
|
-
* Amount owed for change flow: same recipe as normal booking — FE `totalPrice` vs original receipt.
|
|
2932
|
-
* Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
|
|
2933
|
-
*/
|
|
2934
3356
|
const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
|
|
2935
|
-
const changeFlowAmountDue = isChangeFlow
|
|
2936
|
-
|
|
2937
|
-
const rounded = Math.round(changeFlowAmountDueRaw * 100) / 100;
|
|
2938
|
-
return Math.abs(rounded) < 0.01 ? 0 : rounded;
|
|
2939
|
-
})()
|
|
2940
|
-
: changeFlowAmountDueRaw;
|
|
3357
|
+
const changeFlowAmountDue = isChangeFlow ? normalizeNearZeroOwed(changeFlowAmountDueRaw) : changeFlowAmountDueRaw;
|
|
3358
|
+
|
|
2941
3359
|
|
|
2942
3360
|
const changeCheckoutButtonLabel = (() => {
|
|
2943
3361
|
if (!isChangeFlow) return undefined;
|
|
@@ -3039,7 +3457,7 @@ export function BookingFlow({
|
|
|
3039
3457
|
dateChanged: changeSelectionDetails.dateChanged,
|
|
3040
3458
|
ticketsChanged: changeSelectionDetails.ticketsChanged,
|
|
3041
3459
|
hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
|
|
3042
|
-
selectionTotal: totalPrice,
|
|
3460
|
+
selectionTotal: originalReceipt ? changeFlowNewBookingTotal : totalPrice,
|
|
3043
3461
|
selectionCurrency: currency,
|
|
3044
3462
|
};
|
|
3045
3463
|
}, [
|
|
@@ -3052,6 +3470,8 @@ export function BookingFlow({
|
|
|
3052
3470
|
changeSelectionDetails,
|
|
3053
3471
|
totalPrice,
|
|
3054
3472
|
currency,
|
|
3473
|
+
originalReceipt,
|
|
3474
|
+
changeFlowNewBookingTotal,
|
|
3055
3475
|
]);
|
|
3056
3476
|
|
|
3057
3477
|
useEffect(() => {
|
|
@@ -3087,9 +3507,14 @@ export function BookingFlow({
|
|
|
3087
3507
|
return;
|
|
3088
3508
|
}
|
|
3089
3509
|
onPricePreviewChange({
|
|
3090
|
-
subtotal: effectiveSubtotal,
|
|
3091
|
-
tax:
|
|
3092
|
-
|
|
3510
|
+
subtotal: isChangeFlow && originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
|
|
3511
|
+
tax:
|
|
3512
|
+
!isTaxIncludedInPrice
|
|
3513
|
+
? isChangeFlow && originalReceipt
|
|
3514
|
+
? displayChangeFlowTax
|
|
3515
|
+
: effectiveTax
|
|
3516
|
+
: 0,
|
|
3517
|
+
total: isChangeFlow && originalReceipt ? changeFlowNewBookingTotal : totalPrice,
|
|
3093
3518
|
currency,
|
|
3094
3519
|
});
|
|
3095
3520
|
}, [
|
|
@@ -3098,9 +3523,14 @@ export function BookingFlow({
|
|
|
3098
3523
|
totalQuantity,
|
|
3099
3524
|
effectiveSubtotal,
|
|
3100
3525
|
effectiveTax,
|
|
3101
|
-
|
|
3526
|
+
changeFlowNewBookingTotal,
|
|
3527
|
+
displayChangeFlowSubtotal,
|
|
3528
|
+
displayChangeFlowTax,
|
|
3102
3529
|
currency,
|
|
3103
3530
|
isTaxIncludedInPrice,
|
|
3531
|
+
isChangeFlow,
|
|
3532
|
+
originalReceipt,
|
|
3533
|
+
totalPrice,
|
|
3104
3534
|
]);
|
|
3105
3535
|
|
|
3106
3536
|
/** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
|
|
@@ -3155,7 +3585,13 @@ export function BookingFlow({
|
|
|
3155
3585
|
newPassengerCounts: bookingItems,
|
|
3156
3586
|
// Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
|
|
3157
3587
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3158
|
-
clientProposedTotal:
|
|
3588
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
3589
|
+
capacitySeatCredit: {
|
|
3590
|
+
enabled: true,
|
|
3591
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
3592
|
+
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
3593
|
+
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
3594
|
+
},
|
|
3159
3595
|
});
|
|
3160
3596
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
3161
3597
|
const canProceed = quote.canProceed !== false;
|
|
@@ -3200,9 +3636,13 @@ export function BookingFlow({
|
|
|
3200
3636
|
selectedReturnOption?.returnAvailabilityId,
|
|
3201
3637
|
quantities,
|
|
3202
3638
|
addOnSelections,
|
|
3639
|
+
changeFlowInitialTicketCount,
|
|
3640
|
+
changeFlowNewBookingTotal,
|
|
3203
3641
|
totalPrice,
|
|
3204
3642
|
currency,
|
|
3205
3643
|
activeOptions,
|
|
3644
|
+
initialValues?.availabilityId,
|
|
3645
|
+
initialValues?.returnAvailabilityId,
|
|
3206
3646
|
]);
|
|
3207
3647
|
|
|
3208
3648
|
// Auto-select product option when date is selected: most popular if set, otherwise first available.
|
|
@@ -3350,12 +3790,11 @@ export function BookingFlow({
|
|
|
3350
3790
|
|
|
3351
3791
|
const preferBooked =
|
|
3352
3792
|
isChangeFlow && initialReturnIdForSelect
|
|
3353
|
-
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect
|
|
3793
|
+
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
|
|
3354
3794
|
: undefined;
|
|
3355
3795
|
const preferByDateTime =
|
|
3356
3796
|
isChangeFlow && initialReturnDtForSelect && !preferBooked
|
|
3357
3797
|
? sorted.find((opt) => {
|
|
3358
|
-
if (opt.vacancies <= 0) return false;
|
|
3359
3798
|
try {
|
|
3360
3799
|
return (
|
|
3361
3800
|
parseAvailabilityDateTime(opt.dateTime).getTime() ===
|
|
@@ -3778,6 +4217,12 @@ export function BookingFlow({
|
|
|
3778
4217
|
additionalAdjustments: providerAdditionalAdjustments,
|
|
3779
4218
|
}
|
|
3780
4219
|
: undefined,
|
|
4220
|
+
capacitySeatCredit: {
|
|
4221
|
+
enabled: true,
|
|
4222
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
4223
|
+
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
4224
|
+
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
4225
|
+
},
|
|
3781
4226
|
});
|
|
3782
4227
|
setLoading(false);
|
|
3783
4228
|
return;
|
|
@@ -3816,7 +4261,7 @@ export function BookingFlow({
|
|
|
3816
4261
|
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3817
4262
|
newPassengerCounts: bookingItems,
|
|
3818
4263
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3819
|
-
clientProposedTotal:
|
|
4264
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
3820
4265
|
});
|
|
3821
4266
|
const canProceed = quote.canProceed !== false;
|
|
3822
4267
|
const quoteCurrency = (quote.currency || currency) as Currency;
|
|
@@ -3837,7 +4282,11 @@ export function BookingFlow({
|
|
|
3837
4282
|
if (!canProceed) {
|
|
3838
4283
|
throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
|
|
3839
4284
|
}
|
|
3840
|
-
const feChangeDue =
|
|
4285
|
+
const feChangeDue = changeFlowBalanceVsOriginal({
|
|
4286
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4287
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4288
|
+
audience: 'customer',
|
|
4289
|
+
});
|
|
3841
4290
|
const serverAmountDue =
|
|
3842
4291
|
quote.amountDueCents != null
|
|
3843
4292
|
? Math.max(0, quote.amountDueCents / 100)
|
|
@@ -3947,20 +4396,24 @@ export function BookingFlow({
|
|
|
3947
4396
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
3948
4397
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
3949
4398
|
const amountDueForCheckout = isCustomerSelfServeChange
|
|
3950
|
-
?
|
|
4399
|
+
? changeFlowBalanceVsOriginal({
|
|
4400
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4401
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4402
|
+
audience: 'customer',
|
|
4403
|
+
})
|
|
3951
4404
|
: totalPrice;
|
|
3952
4405
|
const lines = [
|
|
3953
|
-
...
|
|
4406
|
+
...ticketLineItemsForChangeFlowDisplay.map((line) => ({
|
|
3954
4407
|
label: line.category,
|
|
3955
4408
|
amount: line.itemTotal,
|
|
3956
4409
|
type: 'TICKET' as const,
|
|
3957
4410
|
quantity: line.qty,
|
|
3958
4411
|
})),
|
|
3959
|
-
...(
|
|
4412
|
+
...(checkoutReturnLineAmount !== 0
|
|
3960
4413
|
? [
|
|
3961
4414
|
{
|
|
3962
4415
|
label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
3963
|
-
amount:
|
|
4416
|
+
amount: checkoutReturnLineAmount,
|
|
3964
4417
|
type: 'RETURN_OPTION' as const,
|
|
3965
4418
|
quantity: totalQuantity,
|
|
3966
4419
|
},
|
|
@@ -4158,7 +4611,7 @@ export function BookingFlow({
|
|
|
4158
4611
|
availabilityProductOptionId,
|
|
4159
4612
|
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4160
4613
|
clientSecret: paymentIntent.clientSecret ?? '',
|
|
4161
|
-
ticketLinesForModal:
|
|
4614
|
+
ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4162
4615
|
const rate = pricing.find((r) => r.category === line.category);
|
|
4163
4616
|
const breakdown = getPriceBreakdown(
|
|
4164
4617
|
line.category,
|
|
@@ -4169,7 +4622,7 @@ export function BookingFlow({
|
|
|
4169
4622
|
return { line, breakdown };
|
|
4170
4623
|
}),
|
|
4171
4624
|
feeLineItems: feeLineItemsWithAddOns,
|
|
4172
|
-
returnPriceAdjustment,
|
|
4625
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4173
4626
|
cancellationPolicyFee,
|
|
4174
4627
|
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4175
4628
|
subtotal: effectiveSubtotal,
|
|
@@ -4185,7 +4638,7 @@ export function BookingFlow({
|
|
|
4185
4638
|
return;
|
|
4186
4639
|
}
|
|
4187
4640
|
|
|
4188
|
-
const ticketLinesForModal: CheckoutModalLineItem[] =
|
|
4641
|
+
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4189
4642
|
const rate = pricing.find((r) => r.category === line.category);
|
|
4190
4643
|
const breakdown = getPriceBreakdown(
|
|
4191
4644
|
line.category,
|
|
@@ -4221,7 +4674,7 @@ export function BookingFlow({
|
|
|
4221
4674
|
: undefined,
|
|
4222
4675
|
ticketLines: ticketLinesForModal,
|
|
4223
4676
|
feeLineItems: feeLineItemsWithAddOns,
|
|
4224
|
-
returnPriceAdjustment,
|
|
4677
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4225
4678
|
cancellationPolicyFee,
|
|
4226
4679
|
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4227
4680
|
subtotal: effectiveSubtotal,
|
|
@@ -4236,7 +4689,7 @@ export function BookingFlow({
|
|
|
4236
4689
|
isCustomerSelfServeChange && originalReceipt
|
|
4237
4690
|
? {
|
|
4238
4691
|
previousTotal: originalReceipt.total,
|
|
4239
|
-
newTotal:
|
|
4692
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
4240
4693
|
differenceTotal: amountDueForCheckout,
|
|
4241
4694
|
}
|
|
4242
4695
|
: undefined,
|
|
@@ -4507,6 +4960,7 @@ export function BookingFlow({
|
|
|
4507
4960
|
availabilitiesByDate={availabilitiesByDate}
|
|
4508
4961
|
selectedDate={selectedDate}
|
|
4509
4962
|
syncVisibleWeekToSelectedDate={isChangeFlow}
|
|
4963
|
+
selectableSoldOutDate={changeFlowOriginalDate}
|
|
4510
4964
|
isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
|
|
4511
4965
|
onDateSelect={(date) => {
|
|
4512
4966
|
setSelectedDate(date);
|
|
@@ -4529,7 +4983,11 @@ export function BookingFlow({
|
|
|
4529
4983
|
currency={currency}
|
|
4530
4984
|
showCapacity={isAdmin}
|
|
4531
4985
|
extraDiscountPercent={calendarDiscountPercent}
|
|
4532
|
-
|
|
4986
|
+
capDiscountBadgesToBookingDate={
|
|
4987
|
+
isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0
|
|
4988
|
+
? changeFlowOriginalDate
|
|
4989
|
+
: null
|
|
4990
|
+
}
|
|
4533
4991
|
/>
|
|
4534
4992
|
</div>
|
|
4535
4993
|
</div>
|
|
@@ -4642,11 +5100,9 @@ export function BookingFlow({
|
|
|
4642
5100
|
selectedVacancies={effectivePartySizeCap}
|
|
4643
5101
|
companyTimezone={companyTimezone}
|
|
4644
5102
|
pickupDateTime={selectedAvailability.dateTime}
|
|
4645
|
-
pickupVacancies={
|
|
5103
|
+
pickupVacancies={effectiveSelectedPickupVacancies}
|
|
4646
5104
|
returnDateTime={selectedReturnOption?.dateTime ?? null}
|
|
4647
|
-
returnVacancies={
|
|
4648
|
-
selectedReturnOption != null ? (selectedReturnOption.vacancies ?? 0) : null
|
|
4649
|
-
}
|
|
5105
|
+
returnVacancies={effectiveSelectedReturnVacancies}
|
|
4650
5106
|
resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
|
|
4651
5107
|
currency={currency}
|
|
4652
5108
|
locale={locale}
|
|
@@ -4673,6 +5129,7 @@ export function BookingFlow({
|
|
|
4673
5129
|
|
|
4674
5130
|
{/* Total and Checkout — shared PriceSummary component */}
|
|
4675
5131
|
{selectedAvailability && (
|
|
5132
|
+
<>
|
|
4676
5133
|
<CheckoutForm
|
|
4677
5134
|
priceSummaryLines={checkoutPriceSummaryLines}
|
|
4678
5135
|
totalPrice={changeFlowAmountDue}
|
|
@@ -4900,6 +5357,7 @@ export function BookingFlow({
|
|
|
4900
5357
|
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
4901
5358
|
}
|
|
4902
5359
|
/>
|
|
5360
|
+
</>
|
|
4903
5361
|
)}
|
|
4904
5362
|
</div>
|
|
4905
5363
|
</>
|