@ticketboothapp/booking 1.2.63 → 1.2.64
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/booking/AdminChangeBookingFlow.tsx +652 -121
- package/src/components/booking/BookingFlow.tsx +2 -3
- package/src/components/booking/ChangeBookingFlow.tsx +651 -118
- package/src/components/booking/ChangeBookingPricingDriftPanel.tsx +130 -5
- package/src/components/booking/booking-flow-types.ts +9 -2
- package/src/lib/booking/change-booking-pricing-drift.ts +157 -9
- package/src/lib/booking/change-flow-pricing.ts +5 -6
- package/src/lib/booking-api.ts +17 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
quoteChangeBooking,
|
|
12
12
|
confirmFreeChangeBooking,
|
|
13
13
|
createChangeBookingPaymentIntent,
|
|
14
|
+
confirmBookingWithoutPayment,
|
|
14
15
|
getAddOns,
|
|
15
16
|
validatePromoCode,
|
|
16
17
|
getPromoDiscount,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
} from '../../lib/booking-constants';
|
|
33
34
|
import { getSundayOfWeek } from '../../lib/booking/sunday-week';
|
|
34
35
|
import { Calendar } from './Calendar';
|
|
36
|
+
import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
|
|
35
37
|
import { ItineraryBox } from './ItineraryBox';
|
|
36
38
|
import { ItineraryPlaceholder } from './ItineraryPlaceholder';
|
|
37
39
|
import { PickupTimeSelector } from './PickupTimeSelector';
|
|
@@ -62,10 +64,11 @@ import {
|
|
|
62
64
|
} from '../../lib/booking/change-flow-pricing';
|
|
63
65
|
import {
|
|
64
66
|
mergePriceSummaryLinesForDrift,
|
|
67
|
+
mergeLineComparisonsWithFullDrift,
|
|
65
68
|
normalizePricingDriftDetailFromQuote,
|
|
66
69
|
normalizeTicketPricingTraceFromQuote,
|
|
67
70
|
sumPriceSummaryLinesMajorUnits,
|
|
68
|
-
|
|
71
|
+
computePricingDriftDelta,
|
|
69
72
|
} from '../../lib/booking/change-booking-pricing-drift';
|
|
70
73
|
import { ChangeBookingPricingDriftPanel } from './ChangeBookingPricingDriftPanel';
|
|
71
74
|
import {
|
|
@@ -96,6 +99,7 @@ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
|
|
|
96
99
|
import { MANAGE_BOOKING_FROM_CHANGE_PAYMENT, MANAGE_BOOKING_QUERY_FROM } from '../../lib/manage-booking-post-checkout';
|
|
97
100
|
import { useBookingHost } from '../../runtime';
|
|
98
101
|
import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
102
|
+
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
99
103
|
import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
|
|
100
104
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
101
105
|
|
|
@@ -110,6 +114,7 @@ export type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
|
110
114
|
*
|
|
111
115
|
* Until the first successful quote, `selfServeCheckoutPlaceholder` still avoids showing unchecked totals.
|
|
112
116
|
*
|
|
117
|
+
* **Provider dashboard** (`onChangeBooking`) — unchanged; manual pricing via `flowUi.providerDashboardChangePricingUi`.
|
|
113
118
|
*/
|
|
114
119
|
function mergeQuoteSliceWithServerPreview(
|
|
115
120
|
slice: ReturnType<typeof sliceChangeQuoteForUi>,
|
|
@@ -406,8 +411,9 @@ function pickOutboundMatchingPreviousSelection(
|
|
|
406
411
|
anchor: { productOptionId: string | null; minutesFromMidnight: number },
|
|
407
412
|
companyTimezone: string,
|
|
408
413
|
optionsMap: Map<string, { mostPopular?: boolean }>,
|
|
414
|
+
isAdmin: boolean
|
|
409
415
|
): Availability | null {
|
|
410
|
-
const selectable = timesForSelectedDate.filter((a) => (a.vacancies ?? 0) > 0);
|
|
416
|
+
const selectable = timesForSelectedDate.filter((a) => (a.vacancies ?? 0) > 0 || isAdmin);
|
|
411
417
|
if (selectable.length === 0) return null;
|
|
412
418
|
|
|
413
419
|
const withOption =
|
|
@@ -441,8 +447,9 @@ function pickReturnMatchingPreviousSelection(
|
|
|
441
447
|
sortedReturnOptions: ReturnOption[],
|
|
442
448
|
anchor: { returnLocation: string; minutesFromMidnight: number },
|
|
443
449
|
companyTimezone: string,
|
|
450
|
+
isAdmin: boolean
|
|
444
451
|
): ReturnOption | null {
|
|
445
|
-
const eligible = sortedReturnOptions.filter((o) => (o.vacancies ?? 0) > 0);
|
|
452
|
+
const eligible = sortedReturnOptions.filter((o) => (o.vacancies ?? 0) > 0 || isAdmin);
|
|
446
453
|
if (eligible.length === 0) return null;
|
|
447
454
|
|
|
448
455
|
const sameLoc = eligible.filter((o) => o.returnLocation === anchor.returnLocation);
|
|
@@ -631,7 +638,7 @@ function resolveInitialAvailabilityFromBooking(
|
|
|
631
638
|
}
|
|
632
639
|
|
|
633
640
|
/**
|
|
634
|
-
* Customer self-serve **change booking** only (no standard new-booking paths).
|
|
641
|
+
* Customer self-serve and provider-dashboard **change booking** only (no standard new-booking paths).
|
|
635
642
|
* Duplicated from {@link NewBookingFlow} intentionally so each flow can evolve independently.
|
|
636
643
|
*/
|
|
637
644
|
export function ChangeBookingFlow({
|
|
@@ -656,6 +663,7 @@ export function ChangeBookingFlow({
|
|
|
656
663
|
partnerPortalBooking = false,
|
|
657
664
|
availabilityPricingProfileId,
|
|
658
665
|
availabilityCancellationPolicyProfileId,
|
|
666
|
+
onChangeBooking,
|
|
659
667
|
}: ChangeBookingFlowProps) {
|
|
660
668
|
/** Always the booking’s sold currency — not the site currency switcher / parent default. */
|
|
661
669
|
const currency = useMemo((): Currency => {
|
|
@@ -667,6 +675,16 @@ export function ChangeBookingFlow({
|
|
|
667
675
|
return DEFAULT_CURRENCY;
|
|
668
676
|
}, [originalReceipt?.currency, initialValues?.currency, currencyFromParent]);
|
|
669
677
|
|
|
678
|
+
const isManualOverrideEligibleLine = (line: { editable: boolean; type?: string; label?: string }): boolean => {
|
|
679
|
+
if (!line.editable) return false;
|
|
680
|
+
const type = (line.type ?? '').toUpperCase();
|
|
681
|
+
const label = (line.label ?? '').toLowerCase();
|
|
682
|
+
const isPromoLikeType =
|
|
683
|
+
type.includes('PROMO') || type.includes('DISCOUNT') || type.includes('VOUCHER') || type.includes('GIFT');
|
|
684
|
+
const isPromoLikeLabel =
|
|
685
|
+
label.includes('promo') || label.includes('discount') || label.includes('voucher') || label.includes('gift');
|
|
686
|
+
return !(isPromoLikeType || isPromoLikeLabel);
|
|
687
|
+
};
|
|
670
688
|
const { env, analytics } = useBookingHost();
|
|
671
689
|
const { t } = useTranslations();
|
|
672
690
|
const { locale } = useLocale();
|
|
@@ -675,12 +693,15 @@ export function ChangeBookingFlow({
|
|
|
675
693
|
const cancellationPolicyProfileIdForAvailabilities =
|
|
676
694
|
(availabilityCancellationPolicyProfileId ?? '').trim() || null;
|
|
677
695
|
const {
|
|
696
|
+
permissions,
|
|
678
697
|
isSimplifiedPricingView,
|
|
679
698
|
onShowManage,
|
|
680
699
|
getSuccessUrl,
|
|
681
700
|
suppressCalendarDateScroll,
|
|
701
|
+
mode: bookingAppMode,
|
|
682
702
|
} = useBookingApp();
|
|
683
703
|
const availabilitiesCache = useAvailabilitiesCache();
|
|
704
|
+
const isAdmin = permissions.viewerRole === 'admin';
|
|
684
705
|
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
685
706
|
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
686
707
|
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
@@ -779,6 +800,12 @@ export function ChangeBookingFlow({
|
|
|
779
800
|
differenceTotal: number;
|
|
780
801
|
};
|
|
781
802
|
} | null>(null);
|
|
803
|
+
/** Admin only: skip sending confirmation at creation (provider dashboard). */
|
|
804
|
+
const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
|
|
805
|
+
/** Admin only: disable all auto communications for this booking (provider dashboard). */
|
|
806
|
+
const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
|
|
807
|
+
/** Admin only: show choice to pay now or confirm without payment (full balance owed). */
|
|
808
|
+
const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
|
|
782
809
|
const hasAppliedInitialValuesRef = useRef(false);
|
|
783
810
|
const hasAppliedInitialQuantitiesRef = useRef(false);
|
|
784
811
|
const hasHydratedAddOnsFromReceiptRef = useRef(false);
|
|
@@ -797,8 +824,10 @@ export function ChangeBookingFlow({
|
|
|
797
824
|
return null;
|
|
798
825
|
}
|
|
799
826
|
}, [initialValues?.dateTime, companyTimezone]);
|
|
827
|
+
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
828
|
+
const isCustomerSelfServeChange = !isProviderDashboardChange;
|
|
800
829
|
/** Do not render catalog-/FE-derived dollar amounts in UI until `quoteChangeBooking` returns `serverDisplay`. */
|
|
801
|
-
const suppressSelfServeCurrencyUi =
|
|
830
|
+
const suppressSelfServeCurrencyUi = isCustomerSelfServeChange;
|
|
802
831
|
|
|
803
832
|
useEffect(() => {
|
|
804
833
|
setPartnerAttributionConfirmed(false);
|
|
@@ -808,13 +837,13 @@ export function ChangeBookingFlow({
|
|
|
808
837
|
* user picks a different return time — baseline is the first auto-selected return for this outbound.
|
|
809
838
|
*/
|
|
810
839
|
const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
|
|
811
|
-
/**
|
|
840
|
+
/** Any change flow (self-serve or provider): promo from booking is fixed — show read-only, never add new. */
|
|
812
841
|
const lockedPromoCode = initialValues?.promoCode?.trim()
|
|
813
842
|
? initialValues.promoCode.trim().toUpperCase()
|
|
814
843
|
: null;
|
|
815
844
|
/** Public self-serve only: cannot reduce tickets below original counts. */
|
|
816
845
|
const changeBookingMinimumQuantities = useMemo(() => {
|
|
817
|
-
if (!initialValues?.bookingItems?.length) return undefined;
|
|
846
|
+
if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
|
|
818
847
|
const m: Record<string, number> = {};
|
|
819
848
|
for (const item of initialValues.bookingItems) {
|
|
820
849
|
const key = item.category?.trim();
|
|
@@ -822,7 +851,30 @@ export function ChangeBookingFlow({
|
|
|
822
851
|
m[key] = Math.max(0, Number(item.count) || 0);
|
|
823
852
|
}
|
|
824
853
|
return m;
|
|
825
|
-
}, [initialValues?.bookingItems]);
|
|
854
|
+
}, [isCustomerSelfServeChange, initialValues?.bookingItems]);
|
|
855
|
+
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
856
|
+
reservationReference: string;
|
|
857
|
+
reservationExpiration?: string;
|
|
858
|
+
checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
|
|
859
|
+
totalAmount: number;
|
|
860
|
+
datePart: string;
|
|
861
|
+
timePart: string;
|
|
862
|
+
availabilityProductOptionId: string;
|
|
863
|
+
itineraryDisplay?: ItineraryDisplayStep[] | null;
|
|
864
|
+
clientSecret: string;
|
|
865
|
+
ticketLinesForModal: CheckoutModalLineItem[];
|
|
866
|
+
feeLineItems: OrderSummary['feeLineItems'];
|
|
867
|
+
returnPriceAdjustment: number;
|
|
868
|
+
cancellationPolicyFee: number;
|
|
869
|
+
cancellationPolicyLabel?: string;
|
|
870
|
+
subtotal: number;
|
|
871
|
+
tax: number;
|
|
872
|
+
totalQuantity: number;
|
|
873
|
+
isTaxIncludedInPrice: boolean;
|
|
874
|
+
taxRate: number;
|
|
875
|
+
promoDiscountAmount: number;
|
|
876
|
+
discountLabel?: string | null;
|
|
877
|
+
} | null>(null);
|
|
826
878
|
const [latestChangeQuote, setLatestChangeQuote] = useState<{
|
|
827
879
|
priceDiff: number;
|
|
828
880
|
currency: Currency;
|
|
@@ -1659,12 +1711,14 @@ export function ChangeBookingFlow({
|
|
|
1659
1711
|
}, [initialValues?.productId, product.productId]);
|
|
1660
1712
|
|
|
1661
1713
|
/**
|
|
1662
|
-
* Receipt pricing on protected seats/fees
|
|
1663
|
-
* + same product option) vs Rule B (`max(receipt, live)` when date or option changes).
|
|
1714
|
+
* Receipt pricing on protected seats/fees — **customer self-serve only**: Rule A (exact receipt unit when same calendar day
|
|
1715
|
+
* + same product option) vs Rule B (`max(receipt, live)` when date or option changes). Provider dashboard uses live catalog.
|
|
1664
1716
|
*/
|
|
1665
1717
|
const changeFlowApplyReceiptPaidFloors = useMemo(
|
|
1666
|
-
() =>
|
|
1667
|
-
|
|
1718
|
+
() =>
|
|
1719
|
+
!isProviderDashboardChange &&
|
|
1720
|
+
changeFlowBookingParentProductIdForFloors === product.productId.trim(),
|
|
1721
|
+
[isProviderDashboardChange, changeFlowBookingParentProductIdForFloors, product.productId],
|
|
1668
1722
|
);
|
|
1669
1723
|
|
|
1670
1724
|
useEffect(() => {
|
|
@@ -1790,27 +1844,27 @@ export function ChangeBookingFlow({
|
|
|
1790
1844
|
|
|
1791
1845
|
const initialAddOnMinQtyByKey = useMemo(() => {
|
|
1792
1846
|
const map = new Map<string, number>();
|
|
1793
|
-
if (
|
|
1847
|
+
if (!isCustomerSelfServeChange) return map;
|
|
1794
1848
|
for (const sel of initialAddOnBaselineSelections) {
|
|
1795
1849
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
1796
1850
|
map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1797
1851
|
}
|
|
1798
1852
|
return map;
|
|
1799
|
-
}, [
|
|
1853
|
+
}, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
|
|
1800
1854
|
|
|
1801
1855
|
const initialAddOnMinTotalByAddOnId = useMemo(() => {
|
|
1802
1856
|
const map = new Map<string, number>();
|
|
1803
|
-
if (
|
|
1857
|
+
if (!isCustomerSelfServeChange) return map;
|
|
1804
1858
|
for (const sel of initialAddOnBaselineSelections) {
|
|
1805
1859
|
const addOnId = sel.addOnId.trim();
|
|
1806
1860
|
map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1807
1861
|
}
|
|
1808
1862
|
return map;
|
|
1809
|
-
}, [
|
|
1863
|
+
}, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
|
|
1810
1864
|
|
|
1811
1865
|
const applyChangeFlowAddOnFloor = useCallback(
|
|
1812
1866
|
(nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => {
|
|
1813
|
-
if (
|
|
1867
|
+
if (!isCustomerSelfServeChange || initialAddOnMinQtyByKey.size === 0) return nextSelections;
|
|
1814
1868
|
const qtyByKey = new Map<string, number>();
|
|
1815
1869
|
for (const sel of nextSelections) {
|
|
1816
1870
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
@@ -1837,7 +1891,7 @@ export function ChangeBookingFlow({
|
|
|
1837
1891
|
return { addOnId, variantId, quantity: qty };
|
|
1838
1892
|
});
|
|
1839
1893
|
},
|
|
1840
|
-
[
|
|
1894
|
+
[isCustomerSelfServeChange, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
|
|
1841
1895
|
);
|
|
1842
1896
|
|
|
1843
1897
|
const updateAddOnSelections = useCallback(
|
|
@@ -2451,14 +2505,14 @@ export function ChangeBookingFlow({
|
|
|
2451
2505
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
2452
2506
|
const serverReturnMap = latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId;
|
|
2453
2507
|
const floor = effectiveChangeFlowReturnUnitFloorPerPerson;
|
|
2454
|
-
const applyReturnFloor = floor != null &&
|
|
2508
|
+
const applyReturnFloor = floor != null && isCustomerSelfServeChange;
|
|
2455
2509
|
return options.map((opt) => {
|
|
2456
2510
|
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2457
2511
|
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2458
2512
|
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2459
2513
|
const flooredPerPerson = applyReturnFloor ? Math.max(rawPerPerson, floor) : rawPerPerson;
|
|
2460
2514
|
let perPerson = flooredPerPerson;
|
|
2461
|
-
if (
|
|
2515
|
+
if (isCustomerSelfServeChange && serverReturnMap && opt.returnAvailabilityId) {
|
|
2462
2516
|
const sid = opt.returnAvailabilityId.trim();
|
|
2463
2517
|
const sp = serverReturnMap[sid];
|
|
2464
2518
|
if (sp != null && Number.isFinite(sp)) {
|
|
@@ -2481,7 +2535,7 @@ export function ChangeBookingFlow({
|
|
|
2481
2535
|
}, [
|
|
2482
2536
|
selectedAvailability?.returnOptions,
|
|
2483
2537
|
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2484
|
-
|
|
2538
|
+
isCustomerSelfServeChange,
|
|
2485
2539
|
currency,
|
|
2486
2540
|
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2487
2541
|
latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId,
|
|
@@ -2490,11 +2544,11 @@ export function ChangeBookingFlow({
|
|
|
2490
2544
|
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2491
2545
|
if (!selectedReturnOption) return selectedReturnOption;
|
|
2492
2546
|
const floor = effectiveChangeFlowReturnUnitFloorPerPerson;
|
|
2493
|
-
const applyReturnFloor = floor != null &&
|
|
2547
|
+
const applyReturnFloor = floor != null && isCustomerSelfServeChange;
|
|
2494
2548
|
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2495
2549
|
let perPerson = rawPerPerson;
|
|
2496
2550
|
const serverReturnMap = latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId;
|
|
2497
|
-
if (
|
|
2551
|
+
if (isCustomerSelfServeChange && serverReturnMap && selectedReturnOption.returnAvailabilityId) {
|
|
2498
2552
|
const sid = selectedReturnOption.returnAvailabilityId.trim();
|
|
2499
2553
|
const sp = serverReturnMap[sid];
|
|
2500
2554
|
if (sp != null && Number.isFinite(sp)) {
|
|
@@ -2515,7 +2569,7 @@ export function ChangeBookingFlow({
|
|
|
2515
2569
|
}, [
|
|
2516
2570
|
selectedReturnOption,
|
|
2517
2571
|
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2518
|
-
|
|
2572
|
+
isCustomerSelfServeChange,
|
|
2519
2573
|
currency,
|
|
2520
2574
|
latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId,
|
|
2521
2575
|
]);
|
|
@@ -2556,7 +2610,7 @@ export function ChangeBookingFlow({
|
|
|
2556
2610
|
/** Quote `ticketUnitPriceByCategory` overrides catalog units for ticket rows (customer self-serve only). */
|
|
2557
2611
|
const pricingForTicketSelector = useMemo(() => {
|
|
2558
2612
|
const m = latestChangeQuote?.serverPreview?.ticketUnitPriceByCategory;
|
|
2559
|
-
if (
|
|
2613
|
+
if (!isCustomerSelfServeChange || !m) return pricing;
|
|
2560
2614
|
return pricing.map((r) => {
|
|
2561
2615
|
const u = m[r.category.toUpperCase()];
|
|
2562
2616
|
if (u == null || !Number.isFinite(u)) return r;
|
|
@@ -2567,7 +2621,7 @@ export function ChangeBookingFlow({
|
|
|
2567
2621
|
baseInDisplayCurrency: u,
|
|
2568
2622
|
};
|
|
2569
2623
|
});
|
|
2570
|
-
}, [pricing, latestChangeQuote?.serverPreview?.ticketUnitPriceByCategory,
|
|
2624
|
+
}, [pricing, latestChangeQuote?.serverPreview?.ticketUnitPriceByCategory, isCustomerSelfServeChange]);
|
|
2571
2625
|
|
|
2572
2626
|
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2573
2627
|
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
@@ -2672,7 +2726,7 @@ export function ChangeBookingFlow({
|
|
|
2672
2726
|
|
|
2673
2727
|
// When return selection (or refreshed availabilities) caps the party below current ticket counts, trim tickets (non-admin).
|
|
2674
2728
|
useEffect(() => {
|
|
2675
|
-
if (
|
|
2729
|
+
if (isAdmin || !selectedAvailability) return;
|
|
2676
2730
|
const cap = effectivePartySizeCap;
|
|
2677
2731
|
if (totalQuantity <= cap) return;
|
|
2678
2732
|
const over = totalQuantity - cap;
|
|
@@ -2700,7 +2754,7 @@ export function ChangeBookingFlow({
|
|
|
2700
2754
|
effectivePartySizeCap,
|
|
2701
2755
|
totalQuantity,
|
|
2702
2756
|
selectedAvailability,
|
|
2703
|
-
|
|
2757
|
+
isAdmin,
|
|
2704
2758
|
changeBookingMinimumQuantities,
|
|
2705
2759
|
]);
|
|
2706
2760
|
|
|
@@ -2774,7 +2828,7 @@ export function ChangeBookingFlow({
|
|
|
2774
2828
|
*/
|
|
2775
2829
|
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2776
2830
|
if (totalQuantity <= 0) return returnPriceAdjustment;
|
|
2777
|
-
if (
|
|
2831
|
+
if (isProviderDashboardChange) return returnPriceAdjustment;
|
|
2778
2832
|
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2779
2833
|
const livePerPerson =
|
|
2780
2834
|
returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
|
|
@@ -2788,7 +2842,7 @@ export function ChangeBookingFlow({
|
|
|
2788
2842
|
}, [
|
|
2789
2843
|
totalQuantity,
|
|
2790
2844
|
returnPriceAdjustment,
|
|
2791
|
-
|
|
2845
|
+
isProviderDashboardChange,
|
|
2792
2846
|
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2793
2847
|
selectedReturnOption,
|
|
2794
2848
|
returnOptionCatalogPerPerson,
|
|
@@ -2799,7 +2853,7 @@ export function ChangeBookingFlow({
|
|
|
2799
2853
|
|
|
2800
2854
|
/** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
|
|
2801
2855
|
const checkoutReturnLineAmount = useMemo(() => {
|
|
2802
|
-
if (
|
|
2856
|
+
if (isCustomerSelfServeChange) {
|
|
2803
2857
|
return changeFlowProtectedReturnAdjustment;
|
|
2804
2858
|
}
|
|
2805
2859
|
if (changeFlowApplyReceiptPaidFloors) {
|
|
@@ -2807,7 +2861,7 @@ export function ChangeBookingFlow({
|
|
|
2807
2861
|
}
|
|
2808
2862
|
return returnPriceAdjustment;
|
|
2809
2863
|
}, [
|
|
2810
|
-
|
|
2864
|
+
isCustomerSelfServeChange,
|
|
2811
2865
|
changeFlowApplyReceiptPaidFloors,
|
|
2812
2866
|
changeFlowProtectedReturnAdjustment,
|
|
2813
2867
|
returnPriceAdjustment,
|
|
@@ -2879,13 +2933,35 @@ export function ChangeBookingFlow({
|
|
|
2879
2933
|
return [...feeLineItems, ...addOnLines];
|
|
2880
2934
|
}, [feeLineItems, addOnSelections, addOns]);
|
|
2881
2935
|
|
|
2936
|
+
const providerPricingUi = flowUi?.providerDashboardChangePricingUi;
|
|
2937
|
+
const providerQuotedLines = providerPricingUi?.quotedLines ?? [];
|
|
2938
|
+
const providerEditableLines = providerQuotedLines.filter((line) => isManualOverrideEligibleLine(line));
|
|
2939
|
+
const showProviderPricingInlineEditor =
|
|
2940
|
+
isProviderDashboardChange && isAdmin && (
|
|
2941
|
+
providerPricingUi?.loading ||
|
|
2942
|
+
providerPricingUi?.error != null ||
|
|
2943
|
+
providerQuotedLines.length > 0
|
|
2944
|
+
);
|
|
2945
|
+
const normalizeReceiptLabel = (value: string): string =>
|
|
2946
|
+
value
|
|
2947
|
+
.toLowerCase()
|
|
2948
|
+
.replace(/\([^)]*\)/g, '')
|
|
2949
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
2950
|
+
.trim();
|
|
2951
|
+
const providerEditableLineByNormalizedLabel = useMemo(() => {
|
|
2952
|
+
const m = new Map<string, (typeof providerEditableLines)[number]>();
|
|
2953
|
+
for (const line of providerEditableLines) {
|
|
2954
|
+
const key = normalizeReceiptLabel(line.label ?? '');
|
|
2955
|
+
if (key) m.set(key, line);
|
|
2956
|
+
}
|
|
2957
|
+
return m;
|
|
2958
|
+
}, [providerEditableLines]);
|
|
2959
|
+
|
|
2882
2960
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2883
2961
|
if (!selectedAvailability) return [];
|
|
2884
2962
|
const returnLineAmount = checkoutReturnLineAmount;
|
|
2885
|
-
/** Show row when a return is selected and either the priced amount is non-zero or a receipt return floor exists (catalog slot can be $0 while floored total is not). */
|
|
2886
2963
|
const showReturnLine =
|
|
2887
|
-
Boolean(selectedReturnOption) &&
|
|
2888
|
-
(Math.abs(returnLineAmount) > 0.0005 || effectiveChangeFlowReturnUnitFloorPerPerson != null);
|
|
2964
|
+
Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
|
|
2889
2965
|
return [
|
|
2890
2966
|
...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
|
|
2891
2967
|
const rate = pricing.find((r) => r.category === line.category);
|
|
@@ -2897,8 +2973,13 @@ export function ChangeBookingFlow({
|
|
|
2897
2973
|
);
|
|
2898
2974
|
return {
|
|
2899
2975
|
kind: 'ticket',
|
|
2900
|
-
lineKey:
|
|
2901
|
-
|
|
2976
|
+
lineKey:
|
|
2977
|
+
showProviderPricingInlineEditor
|
|
2978
|
+
? providerEditableLineByNormalizedLabel.get(normalizeReceiptLabel(line.category))?.lineKey
|
|
2979
|
+
: undefined,
|
|
2980
|
+
editable:
|
|
2981
|
+
showProviderPricingInlineEditor &&
|
|
2982
|
+
providerEditableLineByNormalizedLabel.has(normalizeReceiptLabel(line.category)),
|
|
2902
2983
|
category: line.category,
|
|
2903
2984
|
qty: line.qty,
|
|
2904
2985
|
itemTotal: line.itemTotal,
|
|
@@ -2933,8 +3014,25 @@ export function ChangeBookingFlow({
|
|
|
2933
3014
|
fee.name.toLowerCase().includes('license'));
|
|
2934
3015
|
return {
|
|
2935
3016
|
kind: 'line' as const,
|
|
2936
|
-
lineKey:
|
|
2937
|
-
|
|
3017
|
+
lineKey:
|
|
3018
|
+
showProviderPricingInlineEditor
|
|
3019
|
+
? providerEditableLineByNormalizedLabel.get(
|
|
3020
|
+
normalizeReceiptLabel(
|
|
3021
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
3022
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
3023
|
+
: fee.name
|
|
3024
|
+
)
|
|
3025
|
+
)?.lineKey
|
|
3026
|
+
: undefined,
|
|
3027
|
+
editable:
|
|
3028
|
+
showProviderPricingInlineEditor &&
|
|
3029
|
+
providerEditableLineByNormalizedLabel.has(
|
|
3030
|
+
normalizeReceiptLabel(
|
|
3031
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
3032
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
3033
|
+
: fee.name
|
|
3034
|
+
)
|
|
3035
|
+
),
|
|
2938
3036
|
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2939
3037
|
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2940
3038
|
: fee.name,
|
|
@@ -2960,7 +3058,8 @@ export function ChangeBookingFlow({
|
|
|
2960
3058
|
effectiveCancellationPolicyLabel,
|
|
2961
3059
|
feeLineItemsWithAddOns,
|
|
2962
3060
|
feeLineItems,
|
|
2963
|
-
|
|
3061
|
+
showProviderPricingInlineEditor,
|
|
3062
|
+
providerEditableLineByNormalizedLabel,
|
|
2964
3063
|
]);
|
|
2965
3064
|
|
|
2966
3065
|
const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
|
|
@@ -3131,7 +3230,45 @@ export function ChangeBookingFlow({
|
|
|
3131
3230
|
|
|
3132
3231
|
const clientMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.clientLineItems);
|
|
3133
3232
|
const useBeClientLines = clientMappedFromApi.length > 0;
|
|
3134
|
-
|
|
3233
|
+
/**
|
|
3234
|
+
* Checkout passes tax/discount via PriceSummary props, not always as `priceSummaryLines`. Include them here so
|
|
3235
|
+
* drift rows and their sums align with `changeFlowNewBookingTotal` (subtotal + tax − promo).
|
|
3236
|
+
*/
|
|
3237
|
+
const clientLinesForMerge: PriceSummaryLine[] = (() => {
|
|
3238
|
+
if (useBeClientLines) return clientMappedFromApi;
|
|
3239
|
+
const lines: PriceSummaryLine[] = [...checkoutPriceSummaryLines];
|
|
3240
|
+
const hasTaxLine = lines.some(
|
|
3241
|
+
(l) => l.kind === 'line' && String(l.type ?? '').toUpperCase() === 'TAX',
|
|
3242
|
+
);
|
|
3243
|
+
if (!hasTaxLine && !isTaxIncludedInPrice && Math.abs(effectiveTax) >= 0.005) {
|
|
3244
|
+
lines.push({
|
|
3245
|
+
kind: 'line',
|
|
3246
|
+
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
3247
|
+
amount: effectiveTax,
|
|
3248
|
+
type: 'TAX',
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
const hasPromoSummaryLine = lines.some(
|
|
3252
|
+
(l) =>
|
|
3253
|
+
l.kind === 'line' &&
|
|
3254
|
+
(/PROMO|DISCOUNT|VOUCHER|GIFT/i.test(String(l.type ?? '')) ||
|
|
3255
|
+
(l.amount < -0.005 && /promo|discount/i.test(l.label))),
|
|
3256
|
+
);
|
|
3257
|
+
if (!hasPromoSummaryLine && Math.abs(effectivePromoDiscountAmount) >= 0.005) {
|
|
3258
|
+
const trimmedPromoCode = appliedPromoCode?.trim() ?? '';
|
|
3259
|
+
const promoLabel =
|
|
3260
|
+
trimmedPromoCode.length > 0
|
|
3261
|
+
? `Promo: ${trimmedPromoCode}`
|
|
3262
|
+
: originalReceipt?.promoLabel?.trim() || 'Discount';
|
|
3263
|
+
lines.push({
|
|
3264
|
+
kind: 'line',
|
|
3265
|
+
label: promoLabel,
|
|
3266
|
+
amount: -effectivePromoDiscountAmount,
|
|
3267
|
+
type: 'PROMO_CODE',
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
return lines;
|
|
3271
|
+
})();
|
|
3135
3272
|
|
|
3136
3273
|
const serverMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.serverLineItems);
|
|
3137
3274
|
const useBeServerLines = serverMappedFromApi.length > 0;
|
|
@@ -3175,14 +3312,35 @@ export function ChangeBookingFlow({
|
|
|
3175
3312
|
}
|
|
3176
3313
|
|
|
3177
3314
|
const mergedForDrift = mergePriceSummaryLinesForDrift(clientLinesForMerge, serverLinesForMerge);
|
|
3178
|
-
|
|
3315
|
+
const rows =
|
|
3179
3316
|
api?.lineComparisons && api.lineComparisons.length > 0
|
|
3180
|
-
?
|
|
3317
|
+
? mergeLineComparisonsWithFullDrift(api.lineComparisons, mergedForDrift)
|
|
3181
3318
|
: mergedForDrift;
|
|
3182
3319
|
|
|
3320
|
+
const receiptAnchoring = api?.receiptAnchoring;
|
|
3321
|
+
let driftRows = rows;
|
|
3322
|
+
if (receiptAnchoring) {
|
|
3323
|
+
const adj = roundMoney(
|
|
3324
|
+
receiptAnchoring.reconciledTotalMajorUnits -
|
|
3325
|
+
receiptAnchoring.requestedCatalogLineSumMajorUnits,
|
|
3326
|
+
);
|
|
3327
|
+
if (Math.abs(adj) >= 0.005) {
|
|
3328
|
+
driftRows = [
|
|
3329
|
+
...rows,
|
|
3330
|
+
{
|
|
3331
|
+
key: 'meta:receipt-anchoring',
|
|
3332
|
+
label: 'Receipt anchoring adjustment (BE only — not included in app total)',
|
|
3333
|
+
clientAmount: null,
|
|
3334
|
+
serverAmount: adj,
|
|
3335
|
+
delta: computePricingDriftDelta(null, adj),
|
|
3336
|
+
},
|
|
3337
|
+
];
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3183
3341
|
const hasTotalDelta = totalDelta != null && Math.abs(totalDelta) >= 0.005;
|
|
3184
3342
|
|
|
3185
|
-
if (
|
|
3343
|
+
if (driftRows.length === 0 && !hasTotalDelta) {
|
|
3186
3344
|
return null;
|
|
3187
3345
|
}
|
|
3188
3346
|
|
|
@@ -3211,7 +3369,7 @@ export function ChangeBookingFlow({
|
|
|
3211
3369
|
|
|
3212
3370
|
return (
|
|
3213
3371
|
<ChangeBookingPricingDriftPanel
|
|
3214
|
-
rows={
|
|
3372
|
+
rows={driftRows}
|
|
3215
3373
|
clientTotal={clientTotalForDrift}
|
|
3216
3374
|
serverTotal={serverTotalFromQuote}
|
|
3217
3375
|
totalDelta={totalDelta}
|
|
@@ -3219,6 +3377,7 @@ export function ChangeBookingFlow({
|
|
|
3219
3377
|
locale={locale}
|
|
3220
3378
|
ticketCartDetail={ticketCartDetail.length > 0 ? ticketCartDetail : undefined}
|
|
3221
3379
|
serverTicketPricingTrace={latestChangeQuote.ticketPricingTrace ?? undefined}
|
|
3380
|
+
receiptAnchoring={receiptAnchoring}
|
|
3222
3381
|
footnote={
|
|
3223
3382
|
usesBeLinePayload
|
|
3224
3383
|
? 'Lines use pricingDriftDetail.clientLineItems / serverLineItems when the quote includes them; totals prefer explicit major-unit fields, then sums of those lines, then the live cart / receipt preview.'
|
|
@@ -3237,6 +3396,12 @@ export function ChangeBookingFlow({
|
|
|
3237
3396
|
locale,
|
|
3238
3397
|
ticketLineItemsForChangeFlowDisplay,
|
|
3239
3398
|
pricingForTicketSelector,
|
|
3399
|
+
isTaxIncludedInPrice,
|
|
3400
|
+
effectiveTax,
|
|
3401
|
+
effectivePromoDiscountAmount,
|
|
3402
|
+
appliedPromoCode,
|
|
3403
|
+
originalReceipt?.promoLabel,
|
|
3404
|
+
t,
|
|
3240
3405
|
]);
|
|
3241
3406
|
|
|
3242
3407
|
/** Replaces PriceSummary with non-numeric status until quote returns authoritative totals (no FE dollar amounts). */
|
|
@@ -3426,31 +3591,62 @@ export function ChangeBookingFlow({
|
|
|
3426
3591
|
addOnSelections,
|
|
3427
3592
|
initialAddOnMinQtyByKey,
|
|
3428
3593
|
]);
|
|
3429
|
-
const hasChangeSelection =
|
|
3594
|
+
const hasChangeSelection =
|
|
3595
|
+
isProviderDashboardChange
|
|
3596
|
+
? changeSelectionDetails.hasOperationalChangesFromInitial
|
|
3597
|
+
: changeSelectionDetails.hasChangesFromInitial;
|
|
3430
3598
|
|
|
3431
3599
|
const changeFlowNeedsServerPrice =
|
|
3432
|
-
|
|
3600
|
+
isCustomerSelfServeChange &&
|
|
3433
3601
|
hasChangeSelection &&
|
|
3434
3602
|
!!initialValues?.bookingReference?.trim() &&
|
|
3435
3603
|
!!lastName.trim();
|
|
3436
3604
|
|
|
3437
|
-
const isChangeQuoteBlocked =
|
|
3438
|
-
const requiresReturnInChangeFlow =
|
|
3605
|
+
const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
|
|
3606
|
+
const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
|
|
3439
3607
|
const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
|
|
3440
3608
|
|
|
3441
3609
|
const changeFlowSubmitDisabled =
|
|
3442
3610
|
missingRequiredReturnSelection ||
|
|
3443
|
-
(
|
|
3611
|
+
(isCustomerSelfServeChange &&
|
|
3444
3612
|
changeFlowNeedsServerPrice &&
|
|
3445
3613
|
(changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)));
|
|
3446
3614
|
|
|
3447
|
-
const providerTotalsPreview =
|
|
3448
|
-
|
|
3615
|
+
const providerTotalsPreview =
|
|
3616
|
+
showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
|
|
3617
|
+
? providerPricingUi.totalsPreview
|
|
3618
|
+
: null;
|
|
3619
|
+
const providerHasEditedLineOverrides =
|
|
3620
|
+
isProviderDashboardChange &&
|
|
3621
|
+
providerQuotedLines.some((line) => {
|
|
3622
|
+
if (!isManualOverrideEligibleLine(line)) return false;
|
|
3623
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
3624
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
3625
|
+
if (!Number.isFinite(parsed)) return false;
|
|
3626
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3627
|
+
return Math.abs(rounded - line.amount) > 0.0001;
|
|
3628
|
+
});
|
|
3629
|
+
const providerHasAdditionalAdjustments =
|
|
3630
|
+
isProviderDashboardChange &&
|
|
3631
|
+
(providerPricingUi?.additionalAdjustments ?? []).some((adj) => {
|
|
3632
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3633
|
+
const hasAmount = Number.isFinite(parsed) && parsed > 0;
|
|
3634
|
+
const currentAmount = hasAmount ? (Math.round(parsed * 100) / 100).toFixed(2) : '';
|
|
3635
|
+
const originalAmount = adj.originalAmountInput ?? '';
|
|
3636
|
+
const currentLabel = (adj.label ?? '').trim();
|
|
3637
|
+
const originalLabel = (adj.originalLabel ?? '').trim();
|
|
3638
|
+
const currentMode = adj.mode;
|
|
3639
|
+
const originalMode = adj.originalMode;
|
|
3640
|
+
if (!originalMode) return hasAmount || currentLabel.length > 0;
|
|
3641
|
+
return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
|
|
3642
|
+
});
|
|
3643
|
+
const hasEffectiveChangeSelection =
|
|
3644
|
+
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
3449
3645
|
|
|
3450
3646
|
const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
|
|
3451
3647
|
providerPreview: providerTotalsPreview,
|
|
3452
3648
|
serverQuotePreview:
|
|
3453
|
-
|
|
3649
|
+
isCustomerSelfServeChange && latestChangeQuote?.serverDisplay
|
|
3454
3650
|
? latestChangeQuote.serverDisplay
|
|
3455
3651
|
: null,
|
|
3456
3652
|
fromCart: {
|
|
@@ -3466,13 +3662,13 @@ export function ChangeBookingFlow({
|
|
|
3466
3662
|
const changeFlowClientEstimateDue = (() => {
|
|
3467
3663
|
if (!originalReceipt) return totalPrice;
|
|
3468
3664
|
// Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
|
|
3469
|
-
if (
|
|
3665
|
+
if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
|
|
3470
3666
|
return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
|
|
3471
3667
|
}
|
|
3472
3668
|
return changeFlowBalanceVsOriginal({
|
|
3473
3669
|
newTotal: displayChangeFlowProposedTotal,
|
|
3474
3670
|
originalReceiptTotal: originalReceipt.total,
|
|
3475
|
-
audience: 'customer',
|
|
3671
|
+
audience: isProviderDashboardChange ? 'provider' : 'customer',
|
|
3476
3672
|
});
|
|
3477
3673
|
})();
|
|
3478
3674
|
|
|
@@ -3481,6 +3677,14 @@ export function ChangeBookingFlow({
|
|
|
3481
3677
|
|
|
3482
3678
|
const changeCheckoutButtonLabel = (() => {
|
|
3483
3679
|
if (!hasEffectiveChangeSelection) return undefined;
|
|
3680
|
+
if (isProviderDashboardChange) {
|
|
3681
|
+
const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
|
|
3682
|
+
return est > 0
|
|
3683
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3684
|
+
: est < 0
|
|
3685
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3686
|
+
: 'Change booking (no charge)';
|
|
3687
|
+
}
|
|
3484
3688
|
if (changeFlowNeedsServerPrice) {
|
|
3485
3689
|
if (changeQuoteLoading) {
|
|
3486
3690
|
const tr = t('booking.updatingPrice');
|
|
@@ -3514,8 +3718,39 @@ export function ChangeBookingFlow({
|
|
|
3514
3718
|
const checkoutFormError =
|
|
3515
3719
|
(error || '') ||
|
|
3516
3720
|
(missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
|
|
3517
|
-
(
|
|
3518
|
-
(
|
|
3721
|
+
(isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
3722
|
+
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
3723
|
+
|
|
3724
|
+
const providerPricingOverrides =
|
|
3725
|
+
isProviderDashboardChange && providerQuotedLines.length > 0
|
|
3726
|
+
? providerQuotedLines
|
|
3727
|
+
.filter((line) => isManualOverrideEligibleLine(line))
|
|
3728
|
+
.map((line) => {
|
|
3729
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
3730
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
3731
|
+
if (!Number.isFinite(parsed)) return null;
|
|
3732
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3733
|
+
return Math.abs(rounded - line.amount) > 0.0001
|
|
3734
|
+
? { lineKey: line.lineKey, amount: rounded, reason: 'Provider dashboard override' }
|
|
3735
|
+
: null;
|
|
3736
|
+
})
|
|
3737
|
+
.filter((v): v is { lineKey: string; amount: number; reason: string } => v != null)
|
|
3738
|
+
: [];
|
|
3739
|
+
const providerAdditionalAdjustments =
|
|
3740
|
+
isProviderDashboardChange
|
|
3741
|
+
? (providerPricingUi?.additionalAdjustments ?? [])
|
|
3742
|
+
.map((adj) => {
|
|
3743
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3744
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
3745
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3746
|
+
const signed = adj.mode === 'DISCOUNT' ? -rounded : rounded;
|
|
3747
|
+
return {
|
|
3748
|
+
label: adj.label?.trim() || (signed < 0 ? 'Provider discount' : 'Provider charge'),
|
|
3749
|
+
amount: signed,
|
|
3750
|
+
};
|
|
3751
|
+
})
|
|
3752
|
+
.filter((v): v is { label: string; amount: number } => v != null)
|
|
3753
|
+
: [];
|
|
3519
3754
|
|
|
3520
3755
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
3521
3756
|
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
@@ -3621,7 +3856,7 @@ export function ChangeBookingFlow({
|
|
|
3621
3856
|
|
|
3622
3857
|
/** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
|
|
3623
3858
|
useEffect(() => {
|
|
3624
|
-
if (
|
|
3859
|
+
if (!isCustomerSelfServeChange) {
|
|
3625
3860
|
setChangeQuoteLoading(false);
|
|
3626
3861
|
setChangeQuoteFetchError(null);
|
|
3627
3862
|
setLatestChangeQuote(null);
|
|
@@ -3712,7 +3947,7 @@ export function ChangeBookingFlow({
|
|
|
3712
3947
|
window.clearTimeout(timer);
|
|
3713
3948
|
};
|
|
3714
3949
|
}, [
|
|
3715
|
-
|
|
3950
|
+
isCustomerSelfServeChange,
|
|
3716
3951
|
hasChangeSelection,
|
|
3717
3952
|
selectedAvailability,
|
|
3718
3953
|
selectedAvailability?.dateTime,
|
|
@@ -3763,7 +3998,8 @@ export function ChangeBookingFlow({
|
|
|
3763
3998
|
timesForSelectedDate,
|
|
3764
3999
|
anchor,
|
|
3765
4000
|
companyTimezone,
|
|
3766
|
-
optionsMap
|
|
4001
|
+
optionsMap,
|
|
4002
|
+
isAdmin
|
|
3767
4003
|
);
|
|
3768
4004
|
changeFlowOutboundAnchorRef.current = null;
|
|
3769
4005
|
if (matched) {
|
|
@@ -3854,7 +4090,8 @@ export function ChangeBookingFlow({
|
|
|
3854
4090
|
const fromAnchor = pickReturnMatchingPreviousSelection(
|
|
3855
4091
|
sorted,
|
|
3856
4092
|
returnAnchor,
|
|
3857
|
-
companyTimezone
|
|
4093
|
+
companyTimezone,
|
|
4094
|
+
isAdmin
|
|
3858
4095
|
);
|
|
3859
4096
|
changeFlowReturnAnchorRef.current = null;
|
|
3860
4097
|
if (fromAnchor) {
|
|
@@ -3900,7 +4137,7 @@ export function ChangeBookingFlow({
|
|
|
3900
4137
|
selectedAvailability,
|
|
3901
4138
|
selectedReturnOption,
|
|
3902
4139
|
companyTimezone,
|
|
3903
|
-
|
|
4140
|
+
isAdmin,
|
|
3904
4141
|
initialValues?.returnAvailabilityId,
|
|
3905
4142
|
initialValues?.returnDateTime,
|
|
3906
4143
|
]);
|
|
@@ -3963,9 +4200,9 @@ export function ChangeBookingFlow({
|
|
|
3963
4200
|
const firstWithInventory = dates.find((d) => {
|
|
3964
4201
|
const rows = availabilitiesByDate[d] ?? [];
|
|
3965
4202
|
if (rows.length === 0) return false;
|
|
3966
|
-
if (
|
|
4203
|
+
if (isAdmin) return rows.some((a) => (a.vacancies ?? 0) > 0);
|
|
3967
4204
|
if (
|
|
3968
|
-
|
|
4205
|
+
isCustomerSelfServeChange &&
|
|
3969
4206
|
changeFlowInitialTicketCount > 0
|
|
3970
4207
|
) {
|
|
3971
4208
|
return rows.some(
|
|
@@ -3974,7 +4211,7 @@ export function ChangeBookingFlow({
|
|
|
3974
4211
|
}
|
|
3975
4212
|
return rows.some((a) => (a.vacancies ?? 0) > 0);
|
|
3976
4213
|
});
|
|
3977
|
-
const first = firstWithInventory ?? (
|
|
4214
|
+
const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
|
|
3978
4215
|
if (!first) return;
|
|
3979
4216
|
|
|
3980
4217
|
hasAutoSelectedPartnerDateRef.current = true;
|
|
@@ -3997,8 +4234,8 @@ export function ChangeBookingFlow({
|
|
|
3997
4234
|
selectedDate,
|
|
3998
4235
|
dates,
|
|
3999
4236
|
availabilitiesByDate,
|
|
4000
|
-
|
|
4001
|
-
|
|
4237
|
+
isAdmin,
|
|
4238
|
+
isCustomerSelfServeChange,
|
|
4002
4239
|
changeFlowInitialTicketCount,
|
|
4003
4240
|
getCalendarEffectiveOutboundVacancies,
|
|
4004
4241
|
useWindowScroll,
|
|
@@ -4036,7 +4273,7 @@ export function ChangeBookingFlow({
|
|
|
4036
4273
|
};
|
|
4037
4274
|
|
|
4038
4275
|
const handleQuantityChange = (category: string, delta: number) => {
|
|
4039
|
-
const maxAvailable =
|
|
4276
|
+
const maxAvailable = isAdmin ? Number.MAX_SAFE_INTEGER : effectivePartySizeCap;
|
|
4040
4277
|
const currentQty = quantities[category] || 0;
|
|
4041
4278
|
const minQ =
|
|
4042
4279
|
changeBookingMinimumQuantities != null
|
|
@@ -4044,7 +4281,7 @@ export function ChangeBookingFlow({
|
|
|
4044
4281
|
: 0;
|
|
4045
4282
|
const newQty = Math.max(minQ, currentQty + delta);
|
|
4046
4283
|
// Admin can overbook; non-admin cannot exceed vacancies
|
|
4047
|
-
if (delta > 0 && !
|
|
4284
|
+
if (delta > 0 && !isAdmin && orderSummary.totalQuantity >= maxAvailable) {
|
|
4048
4285
|
return;
|
|
4049
4286
|
}
|
|
4050
4287
|
setQuantities(prev => ({
|
|
@@ -4177,27 +4414,30 @@ export function ChangeBookingFlow({
|
|
|
4177
4414
|
return;
|
|
4178
4415
|
}
|
|
4179
4416
|
|
|
4180
|
-
|
|
4181
|
-
if (!
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4417
|
+
const skipContactFields = isProviderDashboardChange;
|
|
4418
|
+
if (!skipContactFields) {
|
|
4419
|
+
// Validate email (required)
|
|
4420
|
+
if (!email) {
|
|
4421
|
+
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4185
4424
|
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4425
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
4426
|
+
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
4427
|
+
return;
|
|
4428
|
+
}
|
|
4190
4429
|
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4430
|
+
// Validate first name (required)
|
|
4431
|
+
if (!firstName?.trim()) {
|
|
4432
|
+
setError(t('booking.enterFirstName') || 'Please enter your first name');
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4196
4435
|
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4436
|
+
// Validate last name (required for manage booking lookup)
|
|
4437
|
+
if (!lastName?.trim()) {
|
|
4438
|
+
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
4439
|
+
return;
|
|
4440
|
+
}
|
|
4201
4441
|
}
|
|
4202
4442
|
|
|
4203
4443
|
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
@@ -4225,6 +4465,44 @@ export function ChangeBookingFlow({
|
|
|
4225
4465
|
return;
|
|
4226
4466
|
}
|
|
4227
4467
|
|
|
4468
|
+
if (onChangeBooking) {
|
|
4469
|
+
const pickupForChange = pickupLocationId
|
|
4470
|
+
? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
|
|
4471
|
+
: null;
|
|
4472
|
+
await onChangeBooking({
|
|
4473
|
+
productId: availabilityProductOptionId,
|
|
4474
|
+
dateTime: selectedAvailability.dateTime,
|
|
4475
|
+
bookingItems,
|
|
4476
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4477
|
+
pickupLocationId: pickupLocationId ?? null,
|
|
4478
|
+
travelerHotel: pickupForChange?.name ?? null,
|
|
4479
|
+
startTime: selectedAvailability.dateTime ?? null,
|
|
4480
|
+
passengerCount: null,
|
|
4481
|
+
childSafetySeatsCount: null,
|
|
4482
|
+
foodRestrictions: null,
|
|
4483
|
+
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
4484
|
+
cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
|
|
4485
|
+
promoCode: appliedPromoCode ?? null,
|
|
4486
|
+
newTotalAmount: displayChangeFlowProposedTotal,
|
|
4487
|
+
additionalHoursCount: null,
|
|
4488
|
+
pricingAdjustment:
|
|
4489
|
+
providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
|
|
4490
|
+
? {
|
|
4491
|
+
mode: 'MANUAL_LINES',
|
|
4492
|
+
lineOverrides: providerPricingOverrides,
|
|
4493
|
+
additionalAdjustments: providerAdditionalAdjustments,
|
|
4494
|
+
}
|
|
4495
|
+
: undefined,
|
|
4496
|
+
capacitySeatCredit: {
|
|
4497
|
+
enabled: true,
|
|
4498
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
4499
|
+
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
4500
|
+
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
4501
|
+
},
|
|
4502
|
+
});
|
|
4503
|
+
setLoading(false);
|
|
4504
|
+
return;
|
|
4505
|
+
}
|
|
4228
4506
|
|
|
4229
4507
|
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
4230
4508
|
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
@@ -4232,17 +4510,18 @@ export function ChangeBookingFlow({
|
|
|
4232
4510
|
availabilityProductOptionId,
|
|
4233
4511
|
),
|
|
4234
4512
|
forcePartnerPortalChannel: partnerPortalBooking,
|
|
4235
|
-
forceDashboardSource:
|
|
4513
|
+
forceDashboardSource: bookingAppMode === 'provider-dashboard',
|
|
4236
4514
|
});
|
|
4237
4515
|
|
|
4238
4516
|
// Get the hotel name if a pickup location was selected
|
|
4239
4517
|
const selectedPickupLocation = pickupLocationId
|
|
4240
4518
|
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
4241
4519
|
: null;
|
|
4520
|
+
let quotedPriceDiff: number | null = null;
|
|
4242
4521
|
let changeIntentIdForCheckout: string | undefined;
|
|
4243
4522
|
let changeBookingReferenceForPaidFlow: string | undefined;
|
|
4244
4523
|
|
|
4245
|
-
{
|
|
4524
|
+
if (isCustomerSelfServeChange) {
|
|
4246
4525
|
const changeBookingReference = initialValues?.bookingReference?.trim();
|
|
4247
4526
|
const changeLastName = lastName.trim();
|
|
4248
4527
|
if (!changeBookingReference || !changeLastName) {
|
|
@@ -4270,6 +4549,7 @@ export function ChangeBookingFlow({
|
|
|
4270
4549
|
},
|
|
4271
4550
|
currency
|
|
4272
4551
|
);
|
|
4552
|
+
quotedPriceDiff = quoteSlice.priceDiff;
|
|
4273
4553
|
changeBookingReferenceForPaidFlow = changeBookingReference;
|
|
4274
4554
|
changeIntentIdForCheckout = quoteSlice.changeIntentId ?? undefined;
|
|
4275
4555
|
setLatestChangeQuote(
|
|
@@ -4361,11 +4641,13 @@ export function ChangeBookingFlow({
|
|
|
4361
4641
|
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
4362
4642
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
4363
4643
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
4364
|
-
const amountDueForCheckout =
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4644
|
+
const amountDueForCheckout = isCustomerSelfServeChange
|
|
4645
|
+
? changeFlowBalanceVsOriginal({
|
|
4646
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4647
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4648
|
+
audience: 'customer',
|
|
4649
|
+
})
|
|
4650
|
+
: totalPrice;
|
|
4369
4651
|
const lines = [
|
|
4370
4652
|
...ticketLineItemsForChangeFlowDisplay.map((line) => ({
|
|
4371
4653
|
label: line.category,
|
|
@@ -4442,7 +4724,8 @@ export function ChangeBookingFlow({
|
|
|
4442
4724
|
roundingLabel: t('booking.rounding') || 'Rounding',
|
|
4443
4725
|
});
|
|
4444
4726
|
|
|
4445
|
-
const paymentIntent =
|
|
4727
|
+
const paymentIntent = isCustomerSelfServeChange
|
|
4728
|
+
? await createChangeBookingPaymentIntent(
|
|
4446
4729
|
(() => {
|
|
4447
4730
|
const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
|
|
4448
4731
|
if (!id) {
|
|
@@ -4450,8 +4733,76 @@ export function ChangeBookingFlow({
|
|
|
4450
4733
|
}
|
|
4451
4734
|
return id;
|
|
4452
4735
|
})()
|
|
4453
|
-
)
|
|
4736
|
+
)
|
|
4737
|
+
: await createPaymentIntent({
|
|
4738
|
+
productId: product.productId,
|
|
4739
|
+
optionId: availabilityProductOptionId,
|
|
4740
|
+
date: datePart,
|
|
4741
|
+
time: timePart,
|
|
4742
|
+
quantity: totalQuantity,
|
|
4743
|
+
customerEmail: email,
|
|
4744
|
+
customerFirstName: firstName.trim() || undefined,
|
|
4745
|
+
customerLastName: lastName.trim() || undefined,
|
|
4746
|
+
currency: currency,
|
|
4747
|
+
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
4748
|
+
pickupLocationId: pickupLocationId || undefined,
|
|
4749
|
+
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4750
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
|
|
4751
|
+
promoCode: (lockedPromoCode || appliedPromoCode) || undefined,
|
|
4752
|
+
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
4753
|
+
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
4754
|
+
checkoutBreakdown,
|
|
4755
|
+
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
4756
|
+
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
4757
|
+
...bookingSourceContext,
|
|
4758
|
+
});
|
|
4454
4759
|
|
|
4760
|
+
// Admin: show choice to pay now or confirm without payment (customer owes full balance)
|
|
4761
|
+
if (isAdmin) {
|
|
4762
|
+
const adminReservationRef =
|
|
4763
|
+
initialValues?.bookingReference?.trim() ??
|
|
4764
|
+
changeBookingReferenceForPaidFlow ??
|
|
4765
|
+
'';
|
|
4766
|
+
if (!adminReservationRef) {
|
|
4767
|
+
throw new Error('Missing reservation reference for admin payment flow');
|
|
4768
|
+
}
|
|
4769
|
+
setError('');
|
|
4770
|
+
setAdminChoiceData({
|
|
4771
|
+
reservationReference: adminReservationRef,
|
|
4772
|
+
reservationExpiration: undefined,
|
|
4773
|
+
checkoutBreakdown,
|
|
4774
|
+
totalAmount: amountDueForCheckout,
|
|
4775
|
+
datePart,
|
|
4776
|
+
timePart,
|
|
4777
|
+
availabilityProductOptionId,
|
|
4778
|
+
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4779
|
+
clientSecret: paymentIntent.clientSecret ?? '',
|
|
4780
|
+
ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4781
|
+
const rate = pricing.find((r) => r.category === line.category);
|
|
4782
|
+
const breakdown = getPriceBreakdown(
|
|
4783
|
+
line.category,
|
|
4784
|
+
rate?.priceCAD ?? 0,
|
|
4785
|
+
rate?.baseInDisplayCurrency,
|
|
4786
|
+
rate?.appliedAdjustments ?? []
|
|
4787
|
+
);
|
|
4788
|
+
return { line, breakdown };
|
|
4789
|
+
}),
|
|
4790
|
+
feeLineItems: feeLineItemsWithAddOns,
|
|
4791
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4792
|
+
cancellationPolicyFee,
|
|
4793
|
+
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4794
|
+
subtotal: effectiveSubtotal,
|
|
4795
|
+
tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
|
|
4796
|
+
totalQuantity,
|
|
4797
|
+
isTaxIncludedInPrice,
|
|
4798
|
+
taxRate: pricingConfig?.taxRate ?? 0,
|
|
4799
|
+
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
4800
|
+
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
4801
|
+
});
|
|
4802
|
+
setShowAdminPaymentChoice(true);
|
|
4803
|
+
setLoading(false);
|
|
4804
|
+
return;
|
|
4805
|
+
}
|
|
4455
4806
|
|
|
4456
4807
|
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4457
4808
|
const rate = pricing.find((r) => r.category === line.category);
|
|
@@ -4473,7 +4824,7 @@ export function ChangeBookingFlow({
|
|
|
4473
4824
|
// Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
|
|
4474
4825
|
// /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
|
|
4475
4826
|
successUrlOverride:
|
|
4476
|
-
|
|
4827
|
+
isCustomerSelfServeChange && changeBookingReferenceForPaidFlow
|
|
4477
4828
|
? (() => {
|
|
4478
4829
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
4479
4830
|
const ref = encodeURIComponent(
|
|
@@ -4501,7 +4852,7 @@ export function ChangeBookingFlow({
|
|
|
4501
4852
|
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
4502
4853
|
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
4503
4854
|
changeTotals:
|
|
4504
|
-
|
|
4855
|
+
isCustomerSelfServeChange && originalReceipt
|
|
4505
4856
|
? {
|
|
4506
4857
|
previousTotal: originalReceipt.total,
|
|
4507
4858
|
newTotal: displayChangeFlowProposedTotal,
|
|
@@ -4570,7 +4921,85 @@ export function ChangeBookingFlow({
|
|
|
4570
4921
|
}
|
|
4571
4922
|
};
|
|
4572
4923
|
|
|
4924
|
+
const handleConfirmWithoutPayment = async () => {
|
|
4925
|
+
if (!adminChoiceData) return;
|
|
4926
|
+
setLoading(true);
|
|
4927
|
+
setError('');
|
|
4928
|
+
try {
|
|
4929
|
+
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
4930
|
+
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
4931
|
+
product.productId,
|
|
4932
|
+
adminChoiceData.availabilityProductOptionId,
|
|
4933
|
+
),
|
|
4934
|
+
forcePartnerPortalChannel: partnerPortalBooking,
|
|
4935
|
+
forceDashboardSource: bookingAppMode === 'provider-dashboard',
|
|
4936
|
+
});
|
|
4937
|
+
const result = await confirmBookingWithoutPayment({
|
|
4938
|
+
reservationReference: adminChoiceData.reservationReference,
|
|
4939
|
+
productId: product.productId,
|
|
4940
|
+
optionId: adminChoiceData.availabilityProductOptionId,
|
|
4941
|
+
date: adminChoiceData.datePart,
|
|
4942
|
+
time: adminChoiceData.timePart,
|
|
4943
|
+
customerEmail: email || undefined,
|
|
4944
|
+
customerFirstName: firstName.trim() || undefined,
|
|
4945
|
+
customerLastName: lastName.trim() || undefined,
|
|
4946
|
+
currency: currency,
|
|
4947
|
+
travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
|
|
4948
|
+
pickupLocationId: pickupLocationId || undefined,
|
|
4949
|
+
itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
|
|
4950
|
+
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
4951
|
+
skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
|
|
4952
|
+
disableAutoCommunications: disableAutoCommunications ? true : undefined,
|
|
4953
|
+
checkoutBreakdown: adminChoiceData.checkoutBreakdown,
|
|
4954
|
+
depositAmount: 0,
|
|
4955
|
+
balanceAmount: adminChoiceData.totalAmount,
|
|
4956
|
+
totalAmount: adminChoiceData.totalAmount,
|
|
4957
|
+
...bookingSourceContext,
|
|
4958
|
+
});
|
|
4959
|
+
pendingReservationRef.current = null;
|
|
4960
|
+
const ref = formatBookingRefForDisplay(result.bookingReference);
|
|
4961
|
+
const ln = lastName.trim();
|
|
4962
|
+
setShowAdminPaymentChoice(false);
|
|
4963
|
+
setAdminChoiceData(null);
|
|
4964
|
+
onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
|
|
4965
|
+
if (onShowManage) {
|
|
4966
|
+
onShowManage({ ref, lastName: ln });
|
|
4967
|
+
} else {
|
|
4968
|
+
const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
|
|
4969
|
+
window.location.href = `/manage-booking?${params.toString()}`;
|
|
4970
|
+
}
|
|
4971
|
+
} catch (err) {
|
|
4972
|
+
setError(err instanceof Error ? err.message : 'Failed to confirm booking');
|
|
4973
|
+
} finally {
|
|
4974
|
+
setLoading(false);
|
|
4975
|
+
}
|
|
4976
|
+
};
|
|
4573
4977
|
|
|
4978
|
+
const handlePayNow = () => {
|
|
4979
|
+
if (!adminChoiceData) return;
|
|
4980
|
+
setShowAdminPaymentChoice(false);
|
|
4981
|
+
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
4982
|
+
setCheckoutModalData({
|
|
4983
|
+
reservationReference: adminChoiceData.reservationReference,
|
|
4984
|
+
reservationExpiration: adminChoiceData.reservationExpiration,
|
|
4985
|
+
customerLastName: lastName.trim(),
|
|
4986
|
+
ticketLines: adminChoiceData.ticketLinesForModal,
|
|
4987
|
+
feeLineItems: adminChoiceData.feeLineItems,
|
|
4988
|
+
returnPriceAdjustment: adminChoiceData.returnPriceAdjustment,
|
|
4989
|
+
cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
|
|
4990
|
+
cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
|
|
4991
|
+
subtotal: adminChoiceData.subtotal,
|
|
4992
|
+
tax: adminChoiceData.tax,
|
|
4993
|
+
total: adminChoiceData.totalAmount,
|
|
4994
|
+
totalQuantity: adminChoiceData.totalQuantity,
|
|
4995
|
+
isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
|
|
4996
|
+
taxRate: adminChoiceData.taxRate,
|
|
4997
|
+
promoDiscountAmount: adminChoiceData.promoDiscountAmount,
|
|
4998
|
+
discountLabel: adminChoiceData.discountLabel,
|
|
4999
|
+
});
|
|
5000
|
+
setShowCheckoutModal(true);
|
|
5001
|
+
setAdminChoiceData(null);
|
|
5002
|
+
};
|
|
4574
5003
|
|
|
4575
5004
|
if (activeOptions.length === 0) {
|
|
4576
5005
|
return (
|
|
@@ -4582,6 +5011,17 @@ export function ChangeBookingFlow({
|
|
|
4582
5011
|
|
|
4583
5012
|
return (
|
|
4584
5013
|
<div className="booking-flow-root space-y-8">
|
|
5014
|
+
{/* Admin: choose to pay now or confirm without payment (full balance owed) */}
|
|
5015
|
+
<AdminPaymentChoiceModal
|
|
5016
|
+
open={!!(showAdminPaymentChoice && adminChoiceData)}
|
|
5017
|
+
totalAmount={adminChoiceData?.totalAmount ?? 0}
|
|
5018
|
+
currency={currency}
|
|
5019
|
+
loading={loading}
|
|
5020
|
+
error={error}
|
|
5021
|
+
onPayNow={handlePayNow}
|
|
5022
|
+
onConfirmWithoutPayment={handleConfirmWithoutPayment}
|
|
5023
|
+
onCancel={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
|
|
5024
|
+
/>
|
|
4585
5025
|
{checkoutModalData && (
|
|
4586
5026
|
<CheckoutModal
|
|
4587
5027
|
open={showCheckoutModal}
|
|
@@ -4655,12 +5095,12 @@ export function ChangeBookingFlow({
|
|
|
4655
5095
|
syncVisibleWeekToSelectedDate={true}
|
|
4656
5096
|
selectableSoldOutDate={changeFlowOriginalDate}
|
|
4657
5097
|
partySizeRequiredForCalendarSelection={
|
|
4658
|
-
|
|
5098
|
+
isCustomerSelfServeChange && !isAdmin && changeFlowInitialTicketCount > 0
|
|
4659
5099
|
? changeFlowInitialTicketCount
|
|
4660
5100
|
: undefined
|
|
4661
5101
|
}
|
|
4662
5102
|
getEffectiveVacancies={
|
|
4663
|
-
|
|
5103
|
+
isCustomerSelfServeChange && !isAdmin && changeFlowInitialTicketCount > 0
|
|
4664
5104
|
? getCalendarEffectiveOutboundVacancies
|
|
4665
5105
|
: undefined
|
|
4666
5106
|
}
|
|
@@ -4684,7 +5124,7 @@ export function ChangeBookingFlow({
|
|
|
4684
5124
|
earliestDate={earliestAvailabilityDate}
|
|
4685
5125
|
onVisibleRangeChange={handleVisibleRangeChange}
|
|
4686
5126
|
currency={currency}
|
|
4687
|
-
showCapacity={
|
|
5127
|
+
showCapacity={isAdmin}
|
|
4688
5128
|
extraDiscountPercent={calendarDiscountPercent}
|
|
4689
5129
|
capDiscountBadgesToBookingDate={changeFlowOriginalDate}
|
|
4690
5130
|
/>
|
|
@@ -4740,7 +5180,7 @@ export function ChangeBookingFlow({
|
|
|
4740
5180
|
selectedTicketCount={totalQuantity}
|
|
4741
5181
|
optionsMap={optionsMap}
|
|
4742
5182
|
hasAnyMostPopular={hasAnyMostPopular}
|
|
4743
|
-
isAdmin={
|
|
5183
|
+
isAdmin={isAdmin}
|
|
4744
5184
|
pickupLocationSkipped={pickupLocationSkipped}
|
|
4745
5185
|
t={t}
|
|
4746
5186
|
onTimeSelect={handleTimeSelect}
|
|
@@ -4756,7 +5196,7 @@ export function ChangeBookingFlow({
|
|
|
4756
5196
|
companyTimezone={companyTimezone}
|
|
4757
5197
|
currency={currency}
|
|
4758
5198
|
locale={locale}
|
|
4759
|
-
isAdmin={
|
|
5199
|
+
isAdmin={isAdmin}
|
|
4760
5200
|
t={t}
|
|
4761
5201
|
onReturnSelect={(option) => {
|
|
4762
5202
|
const raw = selectedAvailability.returnOptions?.find(
|
|
@@ -4784,7 +5224,7 @@ export function ChangeBookingFlow({
|
|
|
4784
5224
|
resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
|
|
4785
5225
|
currency={currency}
|
|
4786
5226
|
locale={locale}
|
|
4787
|
-
isAdmin={
|
|
5227
|
+
isAdmin={isAdmin}
|
|
4788
5228
|
isSimplifiedPricingView={isSimplifiedPricingView}
|
|
4789
5229
|
t={t}
|
|
4790
5230
|
onQuantityChange={handleQuantityChange}
|
|
@@ -4806,7 +5246,7 @@ export function ChangeBookingFlow({
|
|
|
4806
5246
|
currency={currency}
|
|
4807
5247
|
locale={locale}
|
|
4808
5248
|
onSelectionsChange={updateAddOnSelections}
|
|
4809
|
-
minimumTotalByAddOnId={initialAddOnMinTotalByAddOnId}
|
|
5249
|
+
minimumTotalByAddOnId={isCustomerSelfServeChange ? initialAddOnMinTotalByAddOnId : undefined}
|
|
4810
5250
|
suppressPrices={false}
|
|
4811
5251
|
/>
|
|
4812
5252
|
)}
|
|
@@ -4836,15 +5276,99 @@ export function ChangeBookingFlow({
|
|
|
4836
5276
|
currency={currency}
|
|
4837
5277
|
locale={locale}
|
|
4838
5278
|
t={t}
|
|
4839
|
-
extraBetweenTaxAndTotal={
|
|
4840
|
-
|
|
5279
|
+
extraBetweenTaxAndTotal={
|
|
5280
|
+
<>
|
|
5281
|
+
{showProviderPricingInlineEditor && providerPricingUi?.error ? (
|
|
5282
|
+
<div className="mt-2 text-sm text-red-700">{providerPricingUi.error}</div>
|
|
5283
|
+
) : null}
|
|
5284
|
+
{showProviderPricingInlineEditor &&
|
|
5285
|
+
providerPricingUi?.loading &&
|
|
5286
|
+
providerQuotedLines.length === 0 ? (
|
|
5287
|
+
<div className="mt-2 text-sm text-stone-500">Loading price lines...</div>
|
|
5288
|
+
) : null}
|
|
5289
|
+
{showProviderPricingInlineEditor &&
|
|
5290
|
+
providerPricingUi?.helperText &&
|
|
5291
|
+
!providerPricingUi.error ? (
|
|
5292
|
+
<div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
|
|
5293
|
+
) : null}
|
|
5294
|
+
</>
|
|
5295
|
+
}
|
|
5296
|
+
extraBeforeSubtotal={
|
|
5297
|
+
showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
|
|
5298
|
+
<div className="space-y-1">
|
|
5299
|
+
{providerPricingUi?.additionalAdjustments?.map((adj) => (
|
|
5300
|
+
<div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
|
|
5301
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
5302
|
+
<button
|
|
5303
|
+
type="button"
|
|
5304
|
+
className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
|
|
5305
|
+
onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
|
|
5306
|
+
>
|
|
5307
|
+
-
|
|
5308
|
+
</button>
|
|
5309
|
+
<input
|
|
5310
|
+
type="text"
|
|
5311
|
+
className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
|
|
5312
|
+
placeholder="Line description"
|
|
5313
|
+
value={adj.label}
|
|
5314
|
+
onChange={(e) =>
|
|
5315
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
|
|
5316
|
+
}
|
|
5317
|
+
/>
|
|
5318
|
+
</div>
|
|
5319
|
+
<div className="flex items-center gap-1">
|
|
5320
|
+
<select
|
|
5321
|
+
className="rounded border border-stone-300 px-1 py-0.5 text-xs"
|
|
5322
|
+
value={adj.mode}
|
|
5323
|
+
onChange={(e) =>
|
|
5324
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
5325
|
+
mode: e.target.value as 'DISCOUNT' | 'CHARGE',
|
|
5326
|
+
})
|
|
5327
|
+
}
|
|
5328
|
+
>
|
|
5329
|
+
<option value="DISCOUNT">-</option>
|
|
5330
|
+
<option value="CHARGE">+</option>
|
|
5331
|
+
</select>
|
|
5332
|
+
<input
|
|
5333
|
+
type="text"
|
|
5334
|
+
inputMode="decimal"
|
|
5335
|
+
className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
|
|
5336
|
+
placeholder="0.00"
|
|
5337
|
+
value={adj.amountInput}
|
|
5338
|
+
onChange={(e) =>
|
|
5339
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
5340
|
+
amountInput: e.target.value,
|
|
5341
|
+
})
|
|
5342
|
+
}
|
|
5343
|
+
/>
|
|
5344
|
+
</div>
|
|
5345
|
+
</div>
|
|
5346
|
+
))}
|
|
5347
|
+
<button
|
|
5348
|
+
type="button"
|
|
5349
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
5350
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
5351
|
+
>
|
|
5352
|
+
+ add line item
|
|
5353
|
+
</button>
|
|
5354
|
+
</div>
|
|
5355
|
+
) : showProviderPricingInlineEditor ? (
|
|
5356
|
+
<button
|
|
5357
|
+
type="button"
|
|
5358
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
5359
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
5360
|
+
>
|
|
5361
|
+
+ add line item
|
|
5362
|
+
</button>
|
|
5363
|
+
) : undefined
|
|
5364
|
+
}
|
|
4841
5365
|
firstName={firstName}
|
|
4842
5366
|
lastName={lastName}
|
|
4843
5367
|
email={email}
|
|
4844
5368
|
onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
|
|
4845
5369
|
onLastNameChange={(v) => { setLastName(v); setError(''); }}
|
|
4846
5370
|
onEmailChange={(v) => { setEmail(v); setError(''); }}
|
|
4847
|
-
readOnlyContactFields
|
|
5371
|
+
readOnlyContactFields
|
|
4848
5372
|
pickupLocations={
|
|
4849
5373
|
selectedDate && product.pickupLocations && product.pickupLocations.length > 0
|
|
4850
5374
|
? product.pickupLocations
|
|
@@ -4878,29 +5402,38 @@ export function ChangeBookingFlow({
|
|
|
4878
5402
|
setTermsAccepted(checked);
|
|
4879
5403
|
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
4880
5404
|
}}
|
|
4881
|
-
isAdmin={
|
|
5405
|
+
isAdmin={isAdmin}
|
|
4882
5406
|
showCommunicationAdminSection={false}
|
|
4883
|
-
skipConfirmationCommunications={
|
|
4884
|
-
disableAutoCommunications={
|
|
4885
|
-
onSkipConfirmationChange={
|
|
4886
|
-
onDisableCommunicationsChange={
|
|
5407
|
+
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
5408
|
+
disableAutoCommunications={disableAutoCommunications}
|
|
5409
|
+
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
5410
|
+
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
4887
5411
|
error={checkoutFormError}
|
|
4888
5412
|
loading={loading}
|
|
4889
5413
|
totalQuantity={totalQuantity}
|
|
4890
5414
|
onCheckout={handleCheckout}
|
|
4891
5415
|
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
4892
5416
|
hideSubmitButton={
|
|
4893
|
-
showCheckoutModal ||
|
|
5417
|
+
showCheckoutModal ||
|
|
5418
|
+
showAdminPaymentChoice ||
|
|
5419
|
+
!hasEffectiveChangeSelection ||
|
|
5420
|
+
isChangeQuoteBlocked
|
|
4894
5421
|
}
|
|
4895
5422
|
submitDisabled={changeFlowSubmitDisabled}
|
|
4896
5423
|
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
4897
5424
|
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
4898
5425
|
attributionConfirmed={partnerAttributionConfirmed}
|
|
4899
5426
|
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
4900
|
-
lineAmountInputs={undefined}
|
|
4901
|
-
onLineAmountInputChange={
|
|
4902
|
-
|
|
4903
|
-
|
|
5427
|
+
lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
|
|
5428
|
+
onLineAmountInputChange={
|
|
5429
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
|
|
5430
|
+
}
|
|
5431
|
+
onLineAmountInputBlur={
|
|
5432
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
|
|
5433
|
+
}
|
|
5434
|
+
onLineAmountReset={
|
|
5435
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
5436
|
+
}
|
|
4904
5437
|
/>
|
|
4905
5438
|
</>
|
|
4906
5439
|
)}
|