@ticketboothapp/booking 1.2.95 → 1.2.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_BOOKING_BE_HANDOFF.md +11 -0
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +185 -46
- package/src/components/booking/ChangeBookingFlow.tsx +0 -193
- package/src/components/booking/booking-flow-types.ts +2 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +3 -0
- package/src/lib/booking-api.ts +3 -0
|
@@ -126,6 +126,17 @@ Same selection fields as `ChangeBookingQuoteRequest`, plus:
|
|
|
126
126
|
- `lineItems: Array<{ label?: string; amount?: number; type?: string; quantity?: number }>`
|
|
127
127
|
- `feAmountDueMajorUnits?` (optional signed delta the FE displays; `newTotal - previousTotal`)
|
|
128
128
|
|
|
129
|
+
For admin/provider cross-product changes, FE also sends:
|
|
130
|
+
|
|
131
|
+
- `newParentProductId`: destination parent catalog product id (`p_...`)
|
|
132
|
+
- `newProductId`: destination product option id selected from availability (`po_...` or equivalent), preserved for existing quote/apply compatibility
|
|
133
|
+
|
|
134
|
+
BE should persist both the destination parent product and selected option when applying the change. Capacity checks,
|
|
135
|
+
receipt pricing, add-ons, pickup locations, and itinerary display should resolve against `newParentProductId` +
|
|
136
|
+
`newProductId`, not the booking’s original product. Same-parent receipt-floor rules continue only when the booking
|
|
137
|
+
parent product equals `newParentProductId`; cross-parent changes should price from the destination catalog / FE
|
|
138
|
+
authoritative admin receipt path.
|
|
139
|
+
|
|
129
140
|
### Backend expectations
|
|
130
141
|
|
|
131
142
|
- Validate admin auth + booking/availability eligibility only.
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { parseISO, addWeeks, format, isBefore, isAfter, startOfDay, endOfDay } f
|
|
|
5
5
|
import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
|
|
6
6
|
import {
|
|
7
7
|
getAvailabilities,
|
|
8
|
+
fetchProducts,
|
|
8
9
|
cancelReservation,
|
|
9
10
|
cancelReservationBestEffort,
|
|
10
11
|
createPaymentIntent,
|
|
@@ -687,12 +688,29 @@ function resolveInitialAvailabilityFromBooking(
|
|
|
687
688
|
return { selection: fallback, defer: false };
|
|
688
689
|
}
|
|
689
690
|
|
|
691
|
+
function isPrivateShuttleProduct(product: Pick<Product, 'productType'> | null | undefined): boolean {
|
|
692
|
+
return product?.productType === 'PRIVATE_SHUTTLE';
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function getAdminChangeEligibleProducts(products: Product[], currentProduct: Product): Product[] {
|
|
696
|
+
if (isPrivateShuttleProduct(currentProduct)) return [currentProduct];
|
|
697
|
+
const activeStandardProducts = products.filter(
|
|
698
|
+
(p) =>
|
|
699
|
+
p.status === 'ACTIVE' &&
|
|
700
|
+
!isPrivateShuttleProduct(p) &&
|
|
701
|
+
p.options?.some((o) => o.status === 'ACTIVE'),
|
|
702
|
+
);
|
|
703
|
+
return activeStandardProducts.some((p) => p.productId === currentProduct.productId)
|
|
704
|
+
? activeStandardProducts
|
|
705
|
+
: [currentProduct, ...activeStandardProducts];
|
|
706
|
+
}
|
|
707
|
+
|
|
690
708
|
/**
|
|
691
709
|
* Admin / provider-dashboard **change booking** — literal duplicate of {@link ChangeBookingFlow} for now
|
|
692
710
|
* so ticketbooth can diverge without affecting the public site flow.
|
|
693
711
|
*/
|
|
694
712
|
export function AdminChangeBookingFlow({
|
|
695
|
-
product,
|
|
713
|
+
product: initialProduct,
|
|
696
714
|
productId,
|
|
697
715
|
onBack,
|
|
698
716
|
currency: currencyFromParent,
|
|
@@ -714,6 +732,7 @@ export function AdminChangeBookingFlow({
|
|
|
714
732
|
availabilityPricingProfileId,
|
|
715
733
|
availabilityCancellationPolicyProfileId,
|
|
716
734
|
onChangeBooking,
|
|
735
|
+
changeProductOptions,
|
|
717
736
|
}: ChangeBookingFlowProps) {
|
|
718
737
|
/** Always the booking’s sold currency — not the site currency switcher / parent default. */
|
|
719
738
|
const currency = useMemo((): Currency => {
|
|
@@ -736,6 +755,19 @@ export function AdminChangeBookingFlow({
|
|
|
736
755
|
return !(isPromoLikeType || isPromoLikeLabel);
|
|
737
756
|
};
|
|
738
757
|
const { env, analytics } = useBookingHost();
|
|
758
|
+
const isInitialPrivateShuttleBooking = isPrivateShuttleProduct(initialProduct);
|
|
759
|
+
const [availableChangeProducts, setAvailableChangeProducts] = useState<Product[]>(
|
|
760
|
+
() => getAdminChangeEligibleProducts(changeProductOptions ?? [initialProduct], initialProduct),
|
|
761
|
+
);
|
|
762
|
+
const [selectedChangeProductId, setSelectedChangeProductId] = useState(initialProduct.productId);
|
|
763
|
+
const [changeProductsLoading, setChangeProductsLoading] = useState(false);
|
|
764
|
+
const [changeProductsError, setChangeProductsError] = useState<string | null>(null);
|
|
765
|
+
const product = useMemo(
|
|
766
|
+
() =>
|
|
767
|
+
availableChangeProducts.find((p) => p.productId === selectedChangeProductId) ??
|
|
768
|
+
initialProduct,
|
|
769
|
+
[availableChangeProducts, selectedChangeProductId, initialProduct],
|
|
770
|
+
);
|
|
739
771
|
const { t } = useTranslations();
|
|
740
772
|
const { locale } = useLocale();
|
|
741
773
|
const companyTimezone = useCompanyTimezone(); // Get timezone from context
|
|
@@ -752,6 +784,12 @@ export function AdminChangeBookingFlow({
|
|
|
752
784
|
} = useBookingApp();
|
|
753
785
|
const availabilitiesCache = useAvailabilitiesCache();
|
|
754
786
|
const isAdmin = permissions.viewerRole === 'admin';
|
|
787
|
+
const originalParentProductId = useMemo(() => {
|
|
788
|
+
const pid = initialValues?.productId?.trim();
|
|
789
|
+
if (pid && isParentProductId(pid)) return pid;
|
|
790
|
+
return initialProduct.productId;
|
|
791
|
+
}, [initialValues?.productId, initialProduct.productId]);
|
|
792
|
+
const selectedParentProductChanged = selectedChangeProductId !== originalParentProductId;
|
|
755
793
|
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
756
794
|
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
757
795
|
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
@@ -783,6 +821,83 @@ export function AdminChangeBookingFlow({
|
|
|
783
821
|
Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
|
|
784
822
|
>([]);
|
|
785
823
|
const adminCustomLineIdRef = useRef(0);
|
|
824
|
+
const previousSelectedChangeProductIdRef = useRef(selectedChangeProductId);
|
|
825
|
+
|
|
826
|
+
useEffect(() => {
|
|
827
|
+
if (changeProductOptions?.length) {
|
|
828
|
+
const nextProducts = getAdminChangeEligibleProducts(changeProductOptions, initialProduct);
|
|
829
|
+
setAvailableChangeProducts(nextProducts);
|
|
830
|
+
setSelectedChangeProductId((current) =>
|
|
831
|
+
nextProducts.some((p) => p.productId === current) ? current : initialProduct.productId,
|
|
832
|
+
);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
setAvailableChangeProducts((prev) => {
|
|
836
|
+
const hasInitial = prev.some((p) => p.productId === initialProduct.productId);
|
|
837
|
+
return hasInitial ? prev : [initialProduct, ...prev];
|
|
838
|
+
});
|
|
839
|
+
setSelectedChangeProductId((current) => current || initialProduct.productId);
|
|
840
|
+
}, [initialProduct, changeProductOptions]);
|
|
841
|
+
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
if (changeProductOptions?.length) return;
|
|
844
|
+
if (!isAdmin || !env.COMPANY_ID) return;
|
|
845
|
+
let cancelled = false;
|
|
846
|
+
setChangeProductsLoading(true);
|
|
847
|
+
setChangeProductsError(null);
|
|
848
|
+
fetchProducts(env.COMPANY_ID)
|
|
849
|
+
.then((products) => {
|
|
850
|
+
if (cancelled) return;
|
|
851
|
+
const nextProducts = getAdminChangeEligibleProducts(products, initialProduct);
|
|
852
|
+
setAvailableChangeProducts(nextProducts);
|
|
853
|
+
setSelectedChangeProductId((current) =>
|
|
854
|
+
nextProducts.some((p) => p.productId === current) ? current : initialProduct.productId,
|
|
855
|
+
);
|
|
856
|
+
})
|
|
857
|
+
.catch((err) => {
|
|
858
|
+
if (cancelled) return;
|
|
859
|
+
setChangeProductsError(err instanceof Error ? err.message : 'Failed to load products');
|
|
860
|
+
})
|
|
861
|
+
.finally(() => {
|
|
862
|
+
if (!cancelled) setChangeProductsLoading(false);
|
|
863
|
+
});
|
|
864
|
+
return () => {
|
|
865
|
+
cancelled = true;
|
|
866
|
+
};
|
|
867
|
+
}, [changeProductOptions, isAdmin, env.COMPANY_ID, initialProduct]);
|
|
868
|
+
|
|
869
|
+
useEffect(() => {
|
|
870
|
+
if (previousSelectedChangeProductIdRef.current === selectedChangeProductId) return;
|
|
871
|
+
previousSelectedChangeProductIdRef.current = selectedChangeProductId;
|
|
872
|
+
setAvailabilities([]);
|
|
873
|
+
setSelectedAvailability(null);
|
|
874
|
+
setSelectedReturnOption(null);
|
|
875
|
+
setAddOns([]);
|
|
876
|
+
setPrecomputedPricesByOption(null);
|
|
877
|
+
setPricingConfig(null);
|
|
878
|
+
pricingConfigSetRef.current = false;
|
|
879
|
+
fetchingRef.current = false;
|
|
880
|
+
hasLoadedAvailabilitiesRef.current = false;
|
|
881
|
+
inFlightRangeRef.current = null;
|
|
882
|
+
fetchedRangesRef.current = [];
|
|
883
|
+
pendingRangeRef.current = null;
|
|
884
|
+
earliestAvailabilityDateRef.current = null;
|
|
885
|
+
lastCompletedQuoteInputsKeyRef.current = null;
|
|
886
|
+
inFlightQuoteInputsKeyRef.current = null;
|
|
887
|
+
setLatestChangeQuote(null);
|
|
888
|
+
setChangeQuoteFetchError(null);
|
|
889
|
+
setChangeQuoteLoading(false);
|
|
890
|
+
setError('');
|
|
891
|
+
}, [selectedChangeProductId]);
|
|
892
|
+
|
|
893
|
+
useEffect(() => {
|
|
894
|
+
if (!pickupLocationId) return;
|
|
895
|
+
const pickupLocations = product.pickupLocations ?? [];
|
|
896
|
+
if (pickupLocations.length === 0) return;
|
|
897
|
+
if (pickupLocations.some((loc) => loc.id === pickupLocationId)) return;
|
|
898
|
+
setPickupLocationId(null);
|
|
899
|
+
setPickupLocationSkipped(false);
|
|
900
|
+
}, [pickupLocationId, product.pickupLocations]);
|
|
786
901
|
|
|
787
902
|
// Auto-apply promo code when parent page passes one (e.g. partner pages).
|
|
788
903
|
// Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
|
|
@@ -3574,6 +3689,7 @@ export function AdminChangeBookingFlow({
|
|
|
3574
3689
|
hasOperationalChangesFromInitial: false,
|
|
3575
3690
|
dateChanged: false,
|
|
3576
3691
|
ticketsChanged: false,
|
|
3692
|
+
productChanged: false,
|
|
3577
3693
|
optionChanged: false,
|
|
3578
3694
|
pickupChanged: false,
|
|
3579
3695
|
countsChanged: false,
|
|
@@ -3587,6 +3703,7 @@ export function AdminChangeBookingFlow({
|
|
|
3587
3703
|
hasOperationalChangesFromInitial: false,
|
|
3588
3704
|
dateChanged: false,
|
|
3589
3705
|
ticketsChanged: false,
|
|
3706
|
+
productChanged: false,
|
|
3590
3707
|
optionChanged: false,
|
|
3591
3708
|
pickupChanged: false,
|
|
3592
3709
|
countsChanged: false,
|
|
@@ -3605,6 +3722,7 @@ export function AdminChangeBookingFlow({
|
|
|
3605
3722
|
const optionChanged = Boolean(
|
|
3606
3723
|
selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
|
|
3607
3724
|
);
|
|
3725
|
+
const productChanged = selectedParentProductChanged;
|
|
3608
3726
|
const normalizePickupId = (value: string | null | undefined) => {
|
|
3609
3727
|
const trimmed = value?.trim();
|
|
3610
3728
|
return trimmed ? trimmed : null;
|
|
@@ -3678,6 +3796,7 @@ export function AdminChangeBookingFlow({
|
|
|
3678
3796
|
return {
|
|
3679
3797
|
hasChangesFromInitial:
|
|
3680
3798
|
dateChanged ||
|
|
3799
|
+
productChanged ||
|
|
3681
3800
|
optionChanged ||
|
|
3682
3801
|
pickupChanged ||
|
|
3683
3802
|
countsChanged ||
|
|
@@ -3687,13 +3806,15 @@ export function AdminChangeBookingFlow({
|
|
|
3687
3806
|
// ignore option-id noise and only consider user-visible booking deltas.
|
|
3688
3807
|
hasOperationalChangesFromInitial:
|
|
3689
3808
|
dateChanged ||
|
|
3809
|
+
productChanged ||
|
|
3690
3810
|
pickupChanged ||
|
|
3691
3811
|
countsChanged ||
|
|
3692
3812
|
addOnsChanged ||
|
|
3693
3813
|
returnChanged,
|
|
3694
3814
|
dateChanged,
|
|
3695
3815
|
// Tickets line corresponds to "option + ticket counts"; add-ons and pickup changes affect itinerary but not the ticket label.
|
|
3696
|
-
ticketsChanged: Boolean(optionChanged || countsChanged),
|
|
3816
|
+
ticketsChanged: Boolean(productChanged || optionChanged || countsChanged),
|
|
3817
|
+
productChanged,
|
|
3697
3818
|
optionChanged,
|
|
3698
3819
|
pickupChanged,
|
|
3699
3820
|
countsChanged,
|
|
@@ -3702,6 +3823,7 @@ export function AdminChangeBookingFlow({
|
|
|
3702
3823
|
};
|
|
3703
3824
|
}, [
|
|
3704
3825
|
initialValues,
|
|
3826
|
+
selectedParentProductChanged,
|
|
3705
3827
|
changeFlowResolvedInitialProductOptionId,
|
|
3706
3828
|
selectedAvailability,
|
|
3707
3829
|
selectedReturnOption,
|
|
@@ -3726,6 +3848,7 @@ export function AdminChangeBookingFlow({
|
|
|
3726
3848
|
const changeQuoteInputsKey = useMemo(() => JSON.stringify({
|
|
3727
3849
|
bookingReference: initialValues?.bookingReference?.trim() ?? '',
|
|
3728
3850
|
lastName: lastName.trim().toLowerCase(),
|
|
3851
|
+
parentProductId: product.productId,
|
|
3729
3852
|
optionId: selectedAvailability?.productOptionId?.trim() || activeOptions[0]?.optionId || '',
|
|
3730
3853
|
dateTime: selectedAvailability?.dateTime ?? '',
|
|
3731
3854
|
availabilityId: selectedAvailability?.availabilityId ?? null,
|
|
@@ -3740,6 +3863,7 @@ export function AdminChangeBookingFlow({
|
|
|
3740
3863
|
}), [
|
|
3741
3864
|
initialValues?.bookingReference,
|
|
3742
3865
|
lastName,
|
|
3866
|
+
product.productId,
|
|
3743
3867
|
selectedAvailability?.productOptionId,
|
|
3744
3868
|
selectedAvailability?.dateTime,
|
|
3745
3869
|
selectedAvailability?.availabilityId,
|
|
@@ -3753,8 +3877,8 @@ export function AdminChangeBookingFlow({
|
|
|
3753
3877
|
editableSummaryLineLabelInputs,
|
|
3754
3878
|
useAdminFeAuthoritativeQuote,
|
|
3755
3879
|
]);
|
|
3756
|
-
const
|
|
3757
|
-
const missingRequiredReturnSelection =
|
|
3880
|
+
const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
|
|
3881
|
+
const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
|
|
3758
3882
|
|
|
3759
3883
|
const changeFlowSubmitDisabled =
|
|
3760
3884
|
missingRequiredReturnSelection ||
|
|
@@ -4104,7 +4228,7 @@ export function AdminChangeBookingFlow({
|
|
|
4104
4228
|
|
|
4105
4229
|
const checkoutFormError =
|
|
4106
4230
|
(error || '') ||
|
|
4107
|
-
(missingRequiredReturnSelection ? '
|
|
4231
|
+
(missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
|
|
4108
4232
|
(isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
4109
4233
|
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
4110
4234
|
|
|
@@ -4153,47 +4277,9 @@ export function AdminChangeBookingFlow({
|
|
|
4153
4277
|
});
|
|
4154
4278
|
}, [adminCustomReceiptLines]);
|
|
4155
4279
|
|
|
4156
|
-
/**
|
|
4157
|
-
* Provider `/change` applies from booking item billable rows plus `pricingAdjustment`.
|
|
4158
|
-
* For admin FE-authoritative receipts, the quote/receipt total can include an explicit tax row while the provider
|
|
4159
|
-
* billable-row builder does not add that row on a date-only change. Send it as an automatic adjustment so
|
|
4160
|
-
* `newTotalAmount` and the billable line sum stay in lockstep.
|
|
4161
|
-
*/
|
|
4162
|
-
const providerReceiptTaxAdjustment = useMemo((): Array<{ label: string; amount: number }> => {
|
|
4163
|
-
if (!isProviderDashboardChange || !useAdminFeAuthoritativeQuote) return [];
|
|
4164
|
-
const tax = roundMoney(adminFeAuthoritativeReceipt.tax);
|
|
4165
|
-
if (Math.abs(tax) < 0.005) return [];
|
|
4166
|
-
const providerAlreadyHasTaxLine = providerQuotedLines.some((line) => {
|
|
4167
|
-
const type = String(line.type ?? '').trim().toUpperCase();
|
|
4168
|
-
const label = String(line.label ?? '').trim().toLowerCase();
|
|
4169
|
-
return type === 'TAX' || label.includes('tax');
|
|
4170
|
-
});
|
|
4171
|
-
if (providerAlreadyHasTaxLine) return [];
|
|
4172
|
-
const taxLine = adminFeAuthoritativeReceipt.lineItems.find(
|
|
4173
|
-
(line) => String(line.type ?? '').trim().toUpperCase() === 'TAX'
|
|
4174
|
-
);
|
|
4175
|
-
return [
|
|
4176
|
-
{
|
|
4177
|
-
label: taxLine?.label?.trim() || (t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees'),
|
|
4178
|
-
amount: tax,
|
|
4179
|
-
},
|
|
4180
|
-
];
|
|
4181
|
-
}, [
|
|
4182
|
-
isProviderDashboardChange,
|
|
4183
|
-
useAdminFeAuthoritativeQuote,
|
|
4184
|
-
adminFeAuthoritativeReceipt.tax,
|
|
4185
|
-
adminFeAuthoritativeReceipt.lineItems,
|
|
4186
|
-
providerQuotedLines,
|
|
4187
|
-
t,
|
|
4188
|
-
]);
|
|
4189
|
-
|
|
4190
4280
|
const mergedProviderAdditionalAdjustments = useMemo(
|
|
4191
|
-
() => [
|
|
4192
|
-
|
|
4193
|
-
...adminCustomLinesAsAdditionalAdjustments,
|
|
4194
|
-
...providerReceiptTaxAdjustment,
|
|
4195
|
-
],
|
|
4196
|
-
[providerAdditionalAdjustments, adminCustomLinesAsAdditionalAdjustments, providerReceiptTaxAdjustment]
|
|
4281
|
+
() => [...providerAdditionalAdjustments, ...adminCustomLinesAsAdditionalAdjustments],
|
|
4282
|
+
[providerAdditionalAdjustments, adminCustomLinesAsAdditionalAdjustments]
|
|
4197
4283
|
);
|
|
4198
4284
|
|
|
4199
4285
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
@@ -4355,6 +4441,7 @@ export function AdminChangeBookingFlow({
|
|
|
4355
4441
|
bookingReference: bookingReferenceForQuote,
|
|
4356
4442
|
lastName: lastName.trim(),
|
|
4357
4443
|
newProductId: optionId,
|
|
4444
|
+
newParentProductId: product.productId,
|
|
4358
4445
|
newDateTime: selectedAvailability.dateTime,
|
|
4359
4446
|
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
4360
4447
|
newPickupLocationId: pickupLocationId || null,
|
|
@@ -4626,6 +4713,21 @@ export function AdminChangeBookingFlow({
|
|
|
4626
4713
|
initialValues?.returnDateTime,
|
|
4627
4714
|
]);
|
|
4628
4715
|
|
|
4716
|
+
useEffect(() => {
|
|
4717
|
+
if (!selectedReturnOption) return;
|
|
4718
|
+
const returnOptions = selectedAvailability?.returnOptions ?? [];
|
|
4719
|
+
if (returnOptions.length === 0) {
|
|
4720
|
+
setSelectedReturnOption(null);
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
const updatedReturnOption = returnOptions.find(
|
|
4724
|
+
(opt) => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId,
|
|
4725
|
+
);
|
|
4726
|
+
if (!updatedReturnOption) {
|
|
4727
|
+
setSelectedReturnOption(null);
|
|
4728
|
+
}
|
|
4729
|
+
}, [selectedAvailability?.returnOptions, selectedReturnOption]);
|
|
4730
|
+
|
|
4629
4731
|
// Fetch add-ons when availability (product option) is selected; clear selections when option changes
|
|
4630
4732
|
const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
|
|
4631
4733
|
const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
|
|
@@ -4894,7 +4996,7 @@ export function AdminChangeBookingFlow({
|
|
|
4894
4996
|
return;
|
|
4895
4997
|
}
|
|
4896
4998
|
if (missingRequiredReturnSelection) {
|
|
4897
|
-
setError('
|
|
4999
|
+
setError('Please select a return time for this product.');
|
|
4898
5000
|
return;
|
|
4899
5001
|
}
|
|
4900
5002
|
|
|
@@ -4977,6 +5079,7 @@ export function AdminChangeBookingFlow({
|
|
|
4977
5079
|
bookingReference: changeBookingReference,
|
|
4978
5080
|
lastName: changeLastName,
|
|
4979
5081
|
newProductId: availabilityProductOptionId,
|
|
5082
|
+
newParentProductId: product.productId,
|
|
4980
5083
|
newDateTime: selectedAvailability.dateTime,
|
|
4981
5084
|
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
4982
5085
|
newPickupLocationId: pickupLocationId || null,
|
|
@@ -5091,6 +5194,7 @@ export function AdminChangeBookingFlow({
|
|
|
5091
5194
|
: null;
|
|
5092
5195
|
await onChangeBooking({
|
|
5093
5196
|
productId: availabilityProductOptionId,
|
|
5197
|
+
parentProductId: product.productId,
|
|
5094
5198
|
dateTime: selectedAvailability.dateTime,
|
|
5095
5199
|
bookingItems,
|
|
5096
5200
|
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
@@ -5486,6 +5590,7 @@ export function AdminChangeBookingFlow({
|
|
|
5486
5590
|
: null;
|
|
5487
5591
|
await onChangeBooking({
|
|
5488
5592
|
productId: availabilityProductOptionId,
|
|
5593
|
+
parentProductId: product.productId,
|
|
5489
5594
|
dateTime: selectedAvailability.dateTime,
|
|
5490
5595
|
bookingItems,
|
|
5491
5596
|
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
@@ -5683,6 +5788,40 @@ export function AdminChangeBookingFlow({
|
|
|
5683
5788
|
)}
|
|
5684
5789
|
{isPartialLaunch ? null : (
|
|
5685
5790
|
<div className="booking-calendar-section">
|
|
5791
|
+
{isAdmin && !isInitialPrivateShuttleBooking && availableChangeProducts.length > 1 ? (
|
|
5792
|
+
<div className="mb-6 rounded-lg border border-stone-200 bg-stone-50 p-4">
|
|
5793
|
+
<label
|
|
5794
|
+
className="mb-2 block text-sm font-medium text-stone-800"
|
|
5795
|
+
htmlFor="admin-change-product-select"
|
|
5796
|
+
>
|
|
5797
|
+
Product
|
|
5798
|
+
</label>
|
|
5799
|
+
<select
|
|
5800
|
+
id="admin-change-product-select"
|
|
5801
|
+
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
|
|
5802
|
+
value={selectedChangeProductId}
|
|
5803
|
+
disabled={changeProductsLoading || loading || showCheckoutModal || showAdminPaymentChoice}
|
|
5804
|
+
onChange={(e) => {
|
|
5805
|
+
setSelectedChangeProductId(e.target.value);
|
|
5806
|
+
setSelectedDate('');
|
|
5807
|
+
setImplicitReturnBaselineId(null);
|
|
5808
|
+
changeFlowOutboundAnchorRef.current = null;
|
|
5809
|
+
changeFlowReturnAnchorRef.current = null;
|
|
5810
|
+
}}
|
|
5811
|
+
>
|
|
5812
|
+
{availableChangeProducts.map((p) => (
|
|
5813
|
+
<option key={p.productId} value={p.productId}>
|
|
5814
|
+
{p.name}
|
|
5815
|
+
</option>
|
|
5816
|
+
))}
|
|
5817
|
+
</select>
|
|
5818
|
+
{changeProductsError ? (
|
|
5819
|
+
<div className="mt-2 text-xs text-amber-800">
|
|
5820
|
+
Product list could not be refreshed. Showing the current product only.
|
|
5821
|
+
</div>
|
|
5822
|
+
) : null}
|
|
5823
|
+
</div>
|
|
5824
|
+
) : null}
|
|
5686
5825
|
{loadingAvailabilities && availabilities.length === 0 ? (
|
|
5687
5826
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
5688
5827
|
<div className="booking-loading-spinner" aria-hidden />
|
|
@@ -61,17 +61,11 @@ import {
|
|
|
61
61
|
roundMoney,
|
|
62
62
|
} from '../../lib/booking/change-flow-pricing';
|
|
63
63
|
import {
|
|
64
|
-
mergePriceSummaryLinesForDrift,
|
|
65
|
-
mergeLineComparisonsWithFullDrift,
|
|
66
64
|
normalizePricingDriftDetailFromQuote,
|
|
67
65
|
normalizeTicketPricingTraceFromQuote,
|
|
68
|
-
sumPriceSummaryLinesMajorUnits,
|
|
69
|
-
computePricingDriftDelta,
|
|
70
66
|
} from '../../lib/booking/change-booking-pricing-drift';
|
|
71
|
-
import { ChangeBookingPricingDriftPanel } from './ChangeBookingPricingDriftPanel';
|
|
72
67
|
import {
|
|
73
68
|
buildChangeBookingServerPreview,
|
|
74
|
-
mapQuoteLineItemsToPriceSummaryLines,
|
|
75
69
|
} from '../../lib/booking/change-booking-server-preview';
|
|
76
70
|
import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
|
|
77
71
|
import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
|
|
@@ -3171,190 +3165,6 @@ export function ChangeBookingFlow({
|
|
|
3171
3165
|
originalReceiptTotal: originalReceipt?.total,
|
|
3172
3166
|
});
|
|
3173
3167
|
|
|
3174
|
-
/** When quote blocks checkout, compare FE vs server lines for debugging (prefers `pricingDriftDetail` from API when sent). */
|
|
3175
|
-
const quoteBlockedPricingDrift = useMemo(() => {
|
|
3176
|
-
if (!suppressSelfServeCurrencyUi || latestChangeQuote?.canProceed !== false) return null;
|
|
3177
|
-
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
3178
|
-
|
|
3179
|
-
const api = latestChangeQuote.pricingDriftDetail;
|
|
3180
|
-
const currencyForFmt = (latestChangeQuote.currency ?? currency) as Currency;
|
|
3181
|
-
|
|
3182
|
-
const clientMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.clientLineItems);
|
|
3183
|
-
const useBeClientLines = clientMappedFromApi.length > 0;
|
|
3184
|
-
/**
|
|
3185
|
-
* Checkout passes tax/discount via PriceSummary props, not always as `priceSummaryLines`. Include them here so
|
|
3186
|
-
* drift rows and their sums align with `changeFlowNewBookingTotal` (subtotal + tax − promo).
|
|
3187
|
-
*/
|
|
3188
|
-
const clientLinesForMerge: PriceSummaryLine[] = (() => {
|
|
3189
|
-
if (useBeClientLines) return clientMappedFromApi;
|
|
3190
|
-
const lines: PriceSummaryLine[] = [...checkoutPriceSummaryLines];
|
|
3191
|
-
const hasTaxLine = lines.some(
|
|
3192
|
-
(l) => l.kind === 'line' && String(l.type ?? '').toUpperCase() === 'TAX',
|
|
3193
|
-
);
|
|
3194
|
-
if (!hasTaxLine && !isTaxIncludedInPrice && Math.abs(effectiveTax) >= 0.005) {
|
|
3195
|
-
lines.push({
|
|
3196
|
-
kind: 'line',
|
|
3197
|
-
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
3198
|
-
amount: effectiveTax,
|
|
3199
|
-
type: 'TAX',
|
|
3200
|
-
});
|
|
3201
|
-
}
|
|
3202
|
-
const hasPromoSummaryLine = lines.some(
|
|
3203
|
-
(l) =>
|
|
3204
|
-
l.kind === 'line' &&
|
|
3205
|
-
(/PROMO|DISCOUNT|VOUCHER|GIFT/i.test(String(l.type ?? '')) ||
|
|
3206
|
-
(l.amount < -0.005 && /promo|discount/i.test(l.label))),
|
|
3207
|
-
);
|
|
3208
|
-
if (!hasPromoSummaryLine && Math.abs(effectivePromoDiscountAmount) >= 0.005) {
|
|
3209
|
-
const trimmedPromoCode = appliedPromoCode?.trim() ?? '';
|
|
3210
|
-
const promoLabel =
|
|
3211
|
-
trimmedPromoCode.length > 0
|
|
3212
|
-
? `Promo: ${trimmedPromoCode}`
|
|
3213
|
-
: originalReceipt?.promoLabel?.trim() || 'Discount';
|
|
3214
|
-
lines.push({
|
|
3215
|
-
kind: 'line',
|
|
3216
|
-
label: promoLabel,
|
|
3217
|
-
amount: -effectivePromoDiscountAmount,
|
|
3218
|
-
type: 'PROMO_CODE',
|
|
3219
|
-
});
|
|
3220
|
-
}
|
|
3221
|
-
return lines;
|
|
3222
|
-
})();
|
|
3223
|
-
|
|
3224
|
-
const serverMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.serverLineItems);
|
|
3225
|
-
const useBeServerLines = serverMappedFromApi.length > 0;
|
|
3226
|
-
const serverLinesForMerge = useBeServerLines
|
|
3227
|
-
? serverMappedFromApi
|
|
3228
|
-
: (latestChangeQuote.serverPreview?.priceSummaryLines ?? []);
|
|
3229
|
-
|
|
3230
|
-
const clientTotalForDrift =
|
|
3231
|
-
api?.clientTotalMajorUnits != null && Number.isFinite(api.clientTotalMajorUnits)
|
|
3232
|
-
? api.clientTotalMajorUnits
|
|
3233
|
-
: useBeClientLines
|
|
3234
|
-
? sumPriceSummaryLinesMajorUnits(clientMappedFromApi)
|
|
3235
|
-
: changeFlowNewBookingTotal;
|
|
3236
|
-
|
|
3237
|
-
let totalDelta: number | null = null;
|
|
3238
|
-
if (api?.deltaMajorUnits != null && Number.isFinite(api.deltaMajorUnits)) {
|
|
3239
|
-
totalDelta = roundMoney(api.deltaMajorUnits);
|
|
3240
|
-
}
|
|
3241
|
-
|
|
3242
|
-
/**
|
|
3243
|
-
* Server “price check” total: explicit API fields → sum of BE server lines → derive from delta → receipt preview totals.
|
|
3244
|
-
*/
|
|
3245
|
-
let serverTotalFromQuote: number | undefined =
|
|
3246
|
-
api?.serverTotalMajorUnits != null && Number.isFinite(api.serverTotalMajorUnits)
|
|
3247
|
-
? api.serverTotalMajorUnits
|
|
3248
|
-
: undefined;
|
|
3249
|
-
if (serverTotalFromQuote == null && useBeServerLines) {
|
|
3250
|
-
serverTotalFromQuote = sumPriceSummaryLinesMajorUnits(serverMappedFromApi);
|
|
3251
|
-
}
|
|
3252
|
-
if (serverTotalFromQuote == null && totalDelta != null) {
|
|
3253
|
-
serverTotalFromQuote = roundMoney(clientTotalForDrift - totalDelta);
|
|
3254
|
-
}
|
|
3255
|
-
if (serverTotalFromQuote == null) {
|
|
3256
|
-
serverTotalFromQuote =
|
|
3257
|
-
latestChangeQuote.serverDisplay?.total ??
|
|
3258
|
-
latestChangeQuote.serverPreview?.totalNewBooking ??
|
|
3259
|
-
undefined;
|
|
3260
|
-
}
|
|
3261
|
-
if (totalDelta == null && serverTotalFromQuote != null && Number.isFinite(serverTotalFromQuote)) {
|
|
3262
|
-
totalDelta = roundMoney(clientTotalForDrift - serverTotalFromQuote);
|
|
3263
|
-
}
|
|
3264
|
-
|
|
3265
|
-
const mergedForDrift = mergePriceSummaryLinesForDrift(clientLinesForMerge, serverLinesForMerge);
|
|
3266
|
-
const rows =
|
|
3267
|
-
api?.lineComparisons && api.lineComparisons.length > 0
|
|
3268
|
-
? mergeLineComparisonsWithFullDrift(api.lineComparisons, mergedForDrift)
|
|
3269
|
-
: mergedForDrift;
|
|
3270
|
-
|
|
3271
|
-
const receiptAnchoring = api?.receiptAnchoring;
|
|
3272
|
-
let driftRows = rows;
|
|
3273
|
-
if (receiptAnchoring) {
|
|
3274
|
-
const adj = roundMoney(
|
|
3275
|
-
receiptAnchoring.reconciledTotalMajorUnits -
|
|
3276
|
-
receiptAnchoring.requestedCatalogLineSumMajorUnits,
|
|
3277
|
-
);
|
|
3278
|
-
if (Math.abs(adj) >= 0.005) {
|
|
3279
|
-
driftRows = [
|
|
3280
|
-
...rows,
|
|
3281
|
-
{
|
|
3282
|
-
key: 'meta:receipt-anchoring',
|
|
3283
|
-
label: 'Receipt anchoring adjustment (BE only — not included in app total)',
|
|
3284
|
-
clientAmount: null,
|
|
3285
|
-
serverAmount: adj,
|
|
3286
|
-
delta: computePricingDriftDelta(null, adj),
|
|
3287
|
-
},
|
|
3288
|
-
];
|
|
3289
|
-
}
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
const hasTotalDelta = totalDelta != null && Math.abs(totalDelta) >= 0.005;
|
|
3293
|
-
|
|
3294
|
-
if (driftRows.length === 0 && !hasTotalDelta) {
|
|
3295
|
-
return null;
|
|
3296
|
-
}
|
|
3297
|
-
|
|
3298
|
-
const usesBeLinePayload = useBeClientLines || useBeServerLines;
|
|
3299
|
-
|
|
3300
|
-
const ticketCartDetail = ticketLineItemsForChangeFlowDisplay
|
|
3301
|
-
.filter((l) => (l.qty ?? 0) > 0)
|
|
3302
|
-
.map((line) => {
|
|
3303
|
-
const cat = line.category?.trim().toUpperCase() ?? '';
|
|
3304
|
-
const rate = pricingForTicketSelector.find((r) => r.category.toUpperCase() === cat);
|
|
3305
|
-
const rawList = rate?.baseInDisplayCurrency ?? rate?.priceCAD;
|
|
3306
|
-
const listUnit =
|
|
3307
|
-
rawList != null && Number.isFinite(Number(rawList))
|
|
3308
|
-
? Number(rawList)
|
|
3309
|
-
: line.qty > 0
|
|
3310
|
-
? line.itemTotal / line.qty
|
|
3311
|
-
: 0;
|
|
3312
|
-
return {
|
|
3313
|
-
category: cat,
|
|
3314
|
-
qty: line.qty,
|
|
3315
|
-
listUnitMajor: roundMoney(listUnit),
|
|
3316
|
-
effectiveUnitMajor: roundMoney(line.qty > 0 ? line.itemTotal / line.qty : 0),
|
|
3317
|
-
lineTotalMajor: line.itemTotal,
|
|
3318
|
-
};
|
|
3319
|
-
});
|
|
3320
|
-
|
|
3321
|
-
return (
|
|
3322
|
-
<ChangeBookingPricingDriftPanel
|
|
3323
|
-
rows={driftRows}
|
|
3324
|
-
clientTotal={clientTotalForDrift}
|
|
3325
|
-
serverTotal={serverTotalFromQuote}
|
|
3326
|
-
totalDelta={totalDelta}
|
|
3327
|
-
currency={currencyForFmt}
|
|
3328
|
-
locale={locale}
|
|
3329
|
-
ticketCartDetail={ticketCartDetail.length > 0 ? ticketCartDetail : undefined}
|
|
3330
|
-
serverTicketPricingTrace={latestChangeQuote.ticketPricingTrace ?? undefined}
|
|
3331
|
-
receiptAnchoring={receiptAnchoring}
|
|
3332
|
-
footnote={
|
|
3333
|
-
usesBeLinePayload
|
|
3334
|
-
? '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.'
|
|
3335
|
-
: undefined
|
|
3336
|
-
}
|
|
3337
|
-
/>
|
|
3338
|
-
);
|
|
3339
|
-
}, [
|
|
3340
|
-
suppressSelfServeCurrencyUi,
|
|
3341
|
-
latestChangeQuote,
|
|
3342
|
-
selectedAvailability,
|
|
3343
|
-
totalQuantity,
|
|
3344
|
-
checkoutPriceSummaryLines,
|
|
3345
|
-
changeFlowNewBookingTotal,
|
|
3346
|
-
currency,
|
|
3347
|
-
locale,
|
|
3348
|
-
ticketLineItemsForChangeFlowDisplay,
|
|
3349
|
-
pricingForTicketSelector,
|
|
3350
|
-
isTaxIncludedInPrice,
|
|
3351
|
-
effectiveTax,
|
|
3352
|
-
effectivePromoDiscountAmount,
|
|
3353
|
-
appliedPromoCode,
|
|
3354
|
-
originalReceipt?.promoLabel,
|
|
3355
|
-
t,
|
|
3356
|
-
]);
|
|
3357
|
-
|
|
3358
3168
|
/** Replaces PriceSummary with non-numeric status until quote returns authoritative totals (no FE dollar amounts). */
|
|
3359
3169
|
const selfServeCheckoutPlaceholder = useMemo(() => {
|
|
3360
3170
|
if (!suppressSelfServeCurrencyUi || !selectedAvailability || totalQuantity <= 0) return undefined;
|
|
@@ -3377,7 +3187,6 @@ export function ChangeBookingFlow({
|
|
|
3377
3187
|
return (
|
|
3378
3188
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
|
3379
3189
|
<div>{latestChangeQuote.reasonIfBlocked ?? 'This booking change is not available.'}</div>
|
|
3380
|
-
{quoteBlockedPricingDrift}
|
|
3381
3190
|
</div>
|
|
3382
3191
|
);
|
|
3383
3192
|
}
|
|
@@ -3394,7 +3203,6 @@ export function ChangeBookingFlow({
|
|
|
3394
3203
|
changeQuoteLoading,
|
|
3395
3204
|
changeQuoteFetchError,
|
|
3396
3205
|
latestChangeQuote,
|
|
3397
|
-
quoteBlockedPricingDrift,
|
|
3398
3206
|
t,
|
|
3399
3207
|
]);
|
|
3400
3208
|
|
|
@@ -5058,4 +4866,3 @@ export function ChangeBookingFlow({
|
|
|
5058
4866
|
</div>
|
|
5059
4867
|
);
|
|
5060
4868
|
}
|
|
5061
|
-
|
|
@@ -104,6 +104,8 @@ export interface BookingFlowBaseProps {
|
|
|
104
104
|
availabilityPricingProfileId?: string | null;
|
|
105
105
|
/** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
|
|
106
106
|
availabilityCancellationPolicyProfileId?: string | null;
|
|
107
|
+
/** Admin change-booking: available destination products for switching the booking to another product. */
|
|
108
|
+
changeProductOptions?: Product[];
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
/** Standard (new) reservation flow — no change-booking receipt or callbacks. */
|
|
@@ -2,6 +2,9 @@ import type { Currency } from './CurrencySwitcher';
|
|
|
2
2
|
|
|
3
3
|
/** Payload passed to `onChangeBooking` when applying a dashboard-managed booking change. */
|
|
4
4
|
export type ProviderDashboardChangeBookingPayload = {
|
|
5
|
+
/** Parent catalog product id. Present when admin changes the booking to a different product. */
|
|
6
|
+
parentProductId?: string;
|
|
7
|
+
/** Product option id selected for the new booking. */
|
|
5
8
|
productId: string;
|
|
6
9
|
dateTime: string;
|
|
7
10
|
bookingItems: Array<{ category: string; count: number }>;
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -845,6 +845,9 @@ export interface CreatePaymentIntentResponse {
|
|
|
845
845
|
export interface ChangeBookingQuoteRequest {
|
|
846
846
|
bookingReference: string;
|
|
847
847
|
lastName: string;
|
|
848
|
+
/** Parent catalog product id for cross-product admin/provider changes. */
|
|
849
|
+
newParentProductId?: string | null;
|
|
850
|
+
/** Product option id selected for the new booking; retained as `newProductId` for existing BE compatibility. */
|
|
848
851
|
newProductId: string;
|
|
849
852
|
newDateTime: string;
|
|
850
853
|
/** Outbound availability id for the new selection (must match option + datetime server-side). */
|