@ticketboothapp/booking 1.2.96 → 1.2.98
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 +316 -57
- 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,
|
|
@@ -152,6 +153,49 @@ function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): Pri
|
|
|
152
153
|
});
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
function isPromoDiscountGiftCardOrVoucherLine(line: {
|
|
157
|
+
type?: string | null;
|
|
158
|
+
label?: string | null;
|
|
159
|
+
amount?: number | null;
|
|
160
|
+
}): boolean {
|
|
161
|
+
const type = String(line.type ?? '').trim().toUpperCase();
|
|
162
|
+
if (type === 'PROMO_CODE' || type === 'DISCOUNT' || type === 'GIFT_CARD' || type === 'VOUCHER') {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
const label = String(line.label ?? '').trim().toLowerCase();
|
|
166
|
+
return /promo|discount|voucher|gift\s*card/.test(label);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function deriveOriginalReceiptPromoAdjustment(originalReceipt: ChangeBookingFlowProps['originalReceipt']): {
|
|
170
|
+
amount: number;
|
|
171
|
+
label: string | null;
|
|
172
|
+
type: 'PROMO_CODE' | 'GIFT_CARD' | 'VOUCHER' | 'DISCOUNT';
|
|
173
|
+
} {
|
|
174
|
+
let amount = Math.max(0, Number(originalReceipt?.promoAmount) || 0);
|
|
175
|
+
let label = originalReceipt?.promoLabel?.trim() || null;
|
|
176
|
+
let type: 'PROMO_CODE' | 'GIFT_CARD' | 'VOUCHER' | 'DISCOUNT' = 'PROMO_CODE';
|
|
177
|
+
|
|
178
|
+
for (const line of originalReceipt?.lineItems ?? []) {
|
|
179
|
+
if (!isPromoDiscountGiftCardOrVoucherLine(line)) continue;
|
|
180
|
+
const lineAmount = Number(line.amount) || 0;
|
|
181
|
+
if (lineAmount < -0.005) {
|
|
182
|
+
amount = Math.max(amount, Math.abs(lineAmount));
|
|
183
|
+
if (!label) label = line.label?.trim() || null;
|
|
184
|
+
}
|
|
185
|
+
const lineType = String(line.type ?? '').trim().toUpperCase();
|
|
186
|
+
const lineLabel = String(line.label ?? '').trim().toLowerCase();
|
|
187
|
+
if (lineType === 'GIFT_CARD' || /gift\s*card/.test(lineLabel)) {
|
|
188
|
+
type = 'GIFT_CARD';
|
|
189
|
+
} else if (lineType === 'VOUCHER' || /voucher/.test(lineLabel)) {
|
|
190
|
+
type = 'VOUCHER';
|
|
191
|
+
} else if (lineType === 'DISCOUNT' || /discount/.test(lineLabel)) {
|
|
192
|
+
type = 'DISCOUNT';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { amount: roundMoney(amount), label, type };
|
|
197
|
+
}
|
|
198
|
+
|
|
155
199
|
function resolveTicketQtyFromQuantities(
|
|
156
200
|
category: string,
|
|
157
201
|
quantities: Record<string, number>,
|
|
@@ -687,12 +731,29 @@ function resolveInitialAvailabilityFromBooking(
|
|
|
687
731
|
return { selection: fallback, defer: false };
|
|
688
732
|
}
|
|
689
733
|
|
|
734
|
+
function isPrivateShuttleProduct(product: Pick<Product, 'productType'> | null | undefined): boolean {
|
|
735
|
+
return product?.productType === 'PRIVATE_SHUTTLE';
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function getAdminChangeEligibleProducts(products: Product[], currentProduct: Product): Product[] {
|
|
739
|
+
if (isPrivateShuttleProduct(currentProduct)) return [currentProduct];
|
|
740
|
+
const activeStandardProducts = products.filter(
|
|
741
|
+
(p) =>
|
|
742
|
+
p.status === 'ACTIVE' &&
|
|
743
|
+
!isPrivateShuttleProduct(p) &&
|
|
744
|
+
p.options?.some((o) => o.status === 'ACTIVE'),
|
|
745
|
+
);
|
|
746
|
+
return activeStandardProducts.some((p) => p.productId === currentProduct.productId)
|
|
747
|
+
? activeStandardProducts
|
|
748
|
+
: [currentProduct, ...activeStandardProducts];
|
|
749
|
+
}
|
|
750
|
+
|
|
690
751
|
/**
|
|
691
752
|
* Admin / provider-dashboard **change booking** — literal duplicate of {@link ChangeBookingFlow} for now
|
|
692
753
|
* so ticketbooth can diverge without affecting the public site flow.
|
|
693
754
|
*/
|
|
694
755
|
export function AdminChangeBookingFlow({
|
|
695
|
-
product,
|
|
756
|
+
product: initialProduct,
|
|
696
757
|
productId,
|
|
697
758
|
onBack,
|
|
698
759
|
currency: currencyFromParent,
|
|
@@ -714,6 +775,7 @@ export function AdminChangeBookingFlow({
|
|
|
714
775
|
availabilityPricingProfileId,
|
|
715
776
|
availabilityCancellationPolicyProfileId,
|
|
716
777
|
onChangeBooking,
|
|
778
|
+
changeProductOptions,
|
|
717
779
|
}: ChangeBookingFlowProps) {
|
|
718
780
|
/** Always the booking’s sold currency — not the site currency switcher / parent default. */
|
|
719
781
|
const currency = useMemo((): Currency => {
|
|
@@ -736,6 +798,19 @@ export function AdminChangeBookingFlow({
|
|
|
736
798
|
return !(isPromoLikeType || isPromoLikeLabel);
|
|
737
799
|
};
|
|
738
800
|
const { env, analytics } = useBookingHost();
|
|
801
|
+
const isInitialPrivateShuttleBooking = isPrivateShuttleProduct(initialProduct);
|
|
802
|
+
const [availableChangeProducts, setAvailableChangeProducts] = useState<Product[]>(
|
|
803
|
+
() => getAdminChangeEligibleProducts(changeProductOptions ?? [initialProduct], initialProduct),
|
|
804
|
+
);
|
|
805
|
+
const [selectedChangeProductId, setSelectedChangeProductId] = useState(initialProduct.productId);
|
|
806
|
+
const [changeProductsLoading, setChangeProductsLoading] = useState(false);
|
|
807
|
+
const [changeProductsError, setChangeProductsError] = useState<string | null>(null);
|
|
808
|
+
const product = useMemo(
|
|
809
|
+
() =>
|
|
810
|
+
availableChangeProducts.find((p) => p.productId === selectedChangeProductId) ??
|
|
811
|
+
initialProduct,
|
|
812
|
+
[availableChangeProducts, selectedChangeProductId, initialProduct],
|
|
813
|
+
);
|
|
739
814
|
const { t } = useTranslations();
|
|
740
815
|
const { locale } = useLocale();
|
|
741
816
|
const companyTimezone = useCompanyTimezone(); // Get timezone from context
|
|
@@ -752,6 +827,12 @@ export function AdminChangeBookingFlow({
|
|
|
752
827
|
} = useBookingApp();
|
|
753
828
|
const availabilitiesCache = useAvailabilitiesCache();
|
|
754
829
|
const isAdmin = permissions.viewerRole === 'admin';
|
|
830
|
+
const originalParentProductId = useMemo(() => {
|
|
831
|
+
const pid = initialValues?.productId?.trim();
|
|
832
|
+
if (pid && isParentProductId(pid)) return pid;
|
|
833
|
+
return initialProduct.productId;
|
|
834
|
+
}, [initialValues?.productId, initialProduct.productId]);
|
|
835
|
+
const selectedParentProductChanged = selectedChangeProductId !== originalParentProductId;
|
|
755
836
|
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
756
837
|
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
757
838
|
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
@@ -783,6 +864,83 @@ export function AdminChangeBookingFlow({
|
|
|
783
864
|
Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
|
|
784
865
|
>([]);
|
|
785
866
|
const adminCustomLineIdRef = useRef(0);
|
|
867
|
+
const previousSelectedChangeProductIdRef = useRef(selectedChangeProductId);
|
|
868
|
+
|
|
869
|
+
useEffect(() => {
|
|
870
|
+
if (changeProductOptions?.length) {
|
|
871
|
+
const nextProducts = getAdminChangeEligibleProducts(changeProductOptions, initialProduct);
|
|
872
|
+
setAvailableChangeProducts(nextProducts);
|
|
873
|
+
setSelectedChangeProductId((current) =>
|
|
874
|
+
nextProducts.some((p) => p.productId === current) ? current : initialProduct.productId,
|
|
875
|
+
);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
setAvailableChangeProducts((prev) => {
|
|
879
|
+
const hasInitial = prev.some((p) => p.productId === initialProduct.productId);
|
|
880
|
+
return hasInitial ? prev : [initialProduct, ...prev];
|
|
881
|
+
});
|
|
882
|
+
setSelectedChangeProductId((current) => current || initialProduct.productId);
|
|
883
|
+
}, [initialProduct, changeProductOptions]);
|
|
884
|
+
|
|
885
|
+
useEffect(() => {
|
|
886
|
+
if (changeProductOptions?.length) return;
|
|
887
|
+
if (!isAdmin || !env.COMPANY_ID) return;
|
|
888
|
+
let cancelled = false;
|
|
889
|
+
setChangeProductsLoading(true);
|
|
890
|
+
setChangeProductsError(null);
|
|
891
|
+
fetchProducts(env.COMPANY_ID)
|
|
892
|
+
.then((products) => {
|
|
893
|
+
if (cancelled) return;
|
|
894
|
+
const nextProducts = getAdminChangeEligibleProducts(products, initialProduct);
|
|
895
|
+
setAvailableChangeProducts(nextProducts);
|
|
896
|
+
setSelectedChangeProductId((current) =>
|
|
897
|
+
nextProducts.some((p) => p.productId === current) ? current : initialProduct.productId,
|
|
898
|
+
);
|
|
899
|
+
})
|
|
900
|
+
.catch((err) => {
|
|
901
|
+
if (cancelled) return;
|
|
902
|
+
setChangeProductsError(err instanceof Error ? err.message : 'Failed to load products');
|
|
903
|
+
})
|
|
904
|
+
.finally(() => {
|
|
905
|
+
if (!cancelled) setChangeProductsLoading(false);
|
|
906
|
+
});
|
|
907
|
+
return () => {
|
|
908
|
+
cancelled = true;
|
|
909
|
+
};
|
|
910
|
+
}, [changeProductOptions, isAdmin, env.COMPANY_ID, initialProduct]);
|
|
911
|
+
|
|
912
|
+
useEffect(() => {
|
|
913
|
+
if (previousSelectedChangeProductIdRef.current === selectedChangeProductId) return;
|
|
914
|
+
previousSelectedChangeProductIdRef.current = selectedChangeProductId;
|
|
915
|
+
setAvailabilities([]);
|
|
916
|
+
setSelectedAvailability(null);
|
|
917
|
+
setSelectedReturnOption(null);
|
|
918
|
+
setAddOns([]);
|
|
919
|
+
setPrecomputedPricesByOption(null);
|
|
920
|
+
setPricingConfig(null);
|
|
921
|
+
pricingConfigSetRef.current = false;
|
|
922
|
+
fetchingRef.current = false;
|
|
923
|
+
hasLoadedAvailabilitiesRef.current = false;
|
|
924
|
+
inFlightRangeRef.current = null;
|
|
925
|
+
fetchedRangesRef.current = [];
|
|
926
|
+
pendingRangeRef.current = null;
|
|
927
|
+
earliestAvailabilityDateRef.current = null;
|
|
928
|
+
lastCompletedQuoteInputsKeyRef.current = null;
|
|
929
|
+
inFlightQuoteInputsKeyRef.current = null;
|
|
930
|
+
setLatestChangeQuote(null);
|
|
931
|
+
setChangeQuoteFetchError(null);
|
|
932
|
+
setChangeQuoteLoading(false);
|
|
933
|
+
setError('');
|
|
934
|
+
}, [selectedChangeProductId]);
|
|
935
|
+
|
|
936
|
+
useEffect(() => {
|
|
937
|
+
if (!pickupLocationId) return;
|
|
938
|
+
const pickupLocations = product.pickupLocations ?? [];
|
|
939
|
+
if (pickupLocations.length === 0) return;
|
|
940
|
+
if (pickupLocations.some((loc) => loc.id === pickupLocationId)) return;
|
|
941
|
+
setPickupLocationId(null);
|
|
942
|
+
setPickupLocationSkipped(false);
|
|
943
|
+
}, [pickupLocationId, product.pickupLocations]);
|
|
786
944
|
|
|
787
945
|
// Auto-apply promo code when parent page passes one (e.g. partner pages).
|
|
788
946
|
// Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
|
|
@@ -3150,49 +3308,6 @@ export function AdminChangeBookingFlow({
|
|
|
3150
3308
|
providerEditableLineByNormalizedLabel,
|
|
3151
3309
|
]);
|
|
3152
3310
|
|
|
3153
|
-
const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
|
|
3154
|
-
let raw: PriceSummaryLine[];
|
|
3155
|
-
if (suppressSelfServeCurrencyUi && selfServePricingConfirmed) {
|
|
3156
|
-
const serverLines = latestChangeQuote?.serverPreview?.priceSummaryLines;
|
|
3157
|
-
raw = serverLines && serverLines.length > 0 ? serverLines : checkoutPriceSummaryLines;
|
|
3158
|
-
} else {
|
|
3159
|
-
raw = checkoutPriceSummaryLines;
|
|
3160
|
-
}
|
|
3161
|
-
return omitZeroAmountPromoDiscountSummaryLines(raw);
|
|
3162
|
-
}, [
|
|
3163
|
-
suppressSelfServeCurrencyUi,
|
|
3164
|
-
selfServePricingConfirmed,
|
|
3165
|
-
checkoutPriceSummaryLines,
|
|
3166
|
-
latestChangeQuote?.serverPreview?.priceSummaryLines,
|
|
3167
|
-
]);
|
|
3168
|
-
|
|
3169
|
-
/** Receipt/server lines already include {@link PriceSummary}'s TAX row — do not also pass `taxAmount` or it duplicates. */
|
|
3170
|
-
const priceSummaryLinesIncludeTaxRow = useMemo(
|
|
3171
|
-
() =>
|
|
3172
|
-
checkoutPriceSummaryLinesForCheckout.some(
|
|
3173
|
-
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
|
|
3174
|
-
),
|
|
3175
|
-
[checkoutPriceSummaryLinesForCheckout],
|
|
3176
|
-
);
|
|
3177
|
-
const {
|
|
3178
|
-
editableCheckoutPriceSummaryLines,
|
|
3179
|
-
editableSummaryLineAmountInputs,
|
|
3180
|
-
editableSummaryLineLabelInputs,
|
|
3181
|
-
editableSummaryPreSubtotalDelta,
|
|
3182
|
-
editableSummaryPreSubtotalDebugRows,
|
|
3183
|
-
onLineAmountInputChange: handleEditableLineAmountInputChange,
|
|
3184
|
-
onLineAmountInputBlur: handleEditableLineAmountInputBlur,
|
|
3185
|
-
onLineAmountReset: handleEditableLineAmountReset,
|
|
3186
|
-
} = useEditableSummaryLines(
|
|
3187
|
-
checkoutPriceSummaryLinesForCheckout,
|
|
3188
|
-
{
|
|
3189
|
-
enabled: showProviderPricingInlineEditor,
|
|
3190
|
-
onChange: providerPricingUi?.onLineAmountInputChange,
|
|
3191
|
-
onBlur: providerPricingUi?.onLineAmountInputBlur,
|
|
3192
|
-
onReset: providerPricingUi?.onLineAmountReset,
|
|
3193
|
-
},
|
|
3194
|
-
);
|
|
3195
|
-
|
|
3196
3311
|
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
3197
3312
|
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
3198
3313
|
const [isGiftCard, setIsGiftCard] = useState(false);
|
|
@@ -3208,6 +3323,11 @@ export function AdminChangeBookingFlow({
|
|
|
3208
3323
|
changeBookingPromo: null as { priorAmount?: number } | null,
|
|
3209
3324
|
});
|
|
3210
3325
|
|
|
3326
|
+
const originalReceiptPromoAdjustment = useMemo(
|
|
3327
|
+
() => deriveOriginalReceiptPromoAdjustment(originalReceipt),
|
|
3328
|
+
[originalReceipt],
|
|
3329
|
+
);
|
|
3330
|
+
|
|
3211
3331
|
const promoDiscountFetchKey = useMemo(() => {
|
|
3212
3332
|
if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) return '';
|
|
3213
3333
|
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
@@ -3224,7 +3344,7 @@ export function AdminChangeBookingFlow({
|
|
|
3224
3344
|
currency,
|
|
3225
3345
|
quantitiesSignature,
|
|
3226
3346
|
String(Math.round(effectiveSubtotalForCheckout * 100)),
|
|
3227
|
-
lockedPromoCode ? String(Math.round(
|
|
3347
|
+
lockedPromoCode ? String(Math.round(originalReceiptPromoAdjustment.amount * 100)) : '',
|
|
3228
3348
|
].join('::');
|
|
3229
3349
|
}, [
|
|
3230
3350
|
appliedPromoCode,
|
|
@@ -3238,7 +3358,7 @@ export function AdminChangeBookingFlow({
|
|
|
3238
3358
|
quantitiesSignature,
|
|
3239
3359
|
effectiveSubtotalForCheckout,
|
|
3240
3360
|
lockedPromoCode,
|
|
3241
|
-
|
|
3361
|
+
originalReceiptPromoAdjustment.amount,
|
|
3242
3362
|
]);
|
|
3243
3363
|
|
|
3244
3364
|
promoDiscountParamsRef.current = {
|
|
@@ -3247,7 +3367,7 @@ export function AdminChangeBookingFlow({
|
|
|
3247
3367
|
effectiveSubtotal: effectiveSubtotalForCheckout,
|
|
3248
3368
|
appliedPromoCode,
|
|
3249
3369
|
changeBookingPromo: lockedPromoCode
|
|
3250
|
-
? { priorAmount:
|
|
3370
|
+
? { priorAmount: originalReceiptPromoAdjustment.amount }
|
|
3251
3371
|
: null,
|
|
3252
3372
|
};
|
|
3253
3373
|
|
|
@@ -3321,16 +3441,88 @@ export function AdminChangeBookingFlow({
|
|
|
3321
3441
|
// Change booking: get-promo-discount uses forBookingChange + prior promo from receipt so expired codes stay locked (BE).
|
|
3322
3442
|
const lockedPromoFallbackAmount =
|
|
3323
3443
|
lockedPromoCode
|
|
3324
|
-
? Math.max(0,
|
|
3444
|
+
? Math.max(0, originalReceiptPromoAdjustment.amount)
|
|
3325
3445
|
: 0;
|
|
3326
3446
|
const effectivePromoDiscountAmount =
|
|
3327
3447
|
promoDiscountAmount > 0 ? promoDiscountAmount : lockedPromoFallbackAmount;
|
|
3448
|
+
const effectivePromoLineType =
|
|
3449
|
+
isGiftCard || originalReceiptPromoAdjustment.type === 'GIFT_CARD'
|
|
3450
|
+
? 'GIFT_CARD'
|
|
3451
|
+
: isVoucher || originalReceiptPromoAdjustment.type === 'VOUCHER'
|
|
3452
|
+
? 'VOUCHER'
|
|
3453
|
+
: originalReceiptPromoAdjustment.type;
|
|
3328
3454
|
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotalForCheckout * (pricingConfig?.taxRate ?? 0);
|
|
3329
3455
|
const effectiveTax =
|
|
3330
|
-
effectivePromoDiscountAmount > 0 &&
|
|
3456
|
+
effectivePromoDiscountAmount > 0 && effectivePromoLineType !== 'GIFT_CARD' && effectivePromoLineType !== 'VOUCHER'
|
|
3331
3457
|
? (effectiveSubtotalForCheckout - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3332
3458
|
: taxOnSubtotal;
|
|
3333
3459
|
const totalPrice = effectiveSubtotalForCheckout + effectiveTax - effectivePromoDiscountAmount;
|
|
3460
|
+
|
|
3461
|
+
const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
|
|
3462
|
+
let raw: PriceSummaryLine[];
|
|
3463
|
+
if (suppressSelfServeCurrencyUi && selfServePricingConfirmed) {
|
|
3464
|
+
const serverLines = latestChangeQuote?.serverPreview?.priceSummaryLines;
|
|
3465
|
+
raw = serverLines && serverLines.length > 0 ? serverLines : checkoutPriceSummaryLines;
|
|
3466
|
+
} else {
|
|
3467
|
+
raw = checkoutPriceSummaryLines;
|
|
3468
|
+
}
|
|
3469
|
+
const lines = omitZeroAmountPromoDiscountSummaryLines(raw);
|
|
3470
|
+
const hasPromoSummaryLine = lines.some((line) =>
|
|
3471
|
+
line.kind === 'line' && isPromoDiscountGiftCardOrVoucherLine(line)
|
|
3472
|
+
);
|
|
3473
|
+
if (hasPromoSummaryLine || effectivePromoDiscountAmount < 0.005) return lines;
|
|
3474
|
+
const trimmedPromoCode = appliedPromoCode?.trim() ?? '';
|
|
3475
|
+
const promoLabel =
|
|
3476
|
+
trimmedPromoCode.length > 0
|
|
3477
|
+
? `Promo: ${trimmedPromoCode}`
|
|
3478
|
+
: originalReceiptPromoAdjustment.label || (t('booking.discount') || 'Discount');
|
|
3479
|
+
return [
|
|
3480
|
+
...lines,
|
|
3481
|
+
{
|
|
3482
|
+
kind: 'line' as const,
|
|
3483
|
+
label: promoLabel,
|
|
3484
|
+
amount: -effectivePromoDiscountAmount,
|
|
3485
|
+
type: effectivePromoLineType,
|
|
3486
|
+
},
|
|
3487
|
+
];
|
|
3488
|
+
}, [
|
|
3489
|
+
suppressSelfServeCurrencyUi,
|
|
3490
|
+
selfServePricingConfirmed,
|
|
3491
|
+
checkoutPriceSummaryLines,
|
|
3492
|
+
latestChangeQuote?.serverPreview?.priceSummaryLines,
|
|
3493
|
+
effectivePromoDiscountAmount,
|
|
3494
|
+
effectivePromoLineType,
|
|
3495
|
+
appliedPromoCode,
|
|
3496
|
+
originalReceiptPromoAdjustment.label,
|
|
3497
|
+
t,
|
|
3498
|
+
]);
|
|
3499
|
+
|
|
3500
|
+
/** Receipt/server lines already include {@link PriceSummary}'s TAX row — do not also pass `taxAmount` or it duplicates. */
|
|
3501
|
+
const priceSummaryLinesIncludeTaxRow = useMemo(
|
|
3502
|
+
() =>
|
|
3503
|
+
checkoutPriceSummaryLinesForCheckout.some(
|
|
3504
|
+
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
|
|
3505
|
+
),
|
|
3506
|
+
[checkoutPriceSummaryLinesForCheckout],
|
|
3507
|
+
);
|
|
3508
|
+
const {
|
|
3509
|
+
editableCheckoutPriceSummaryLines,
|
|
3510
|
+
editableSummaryLineAmountInputs,
|
|
3511
|
+
editableSummaryLineLabelInputs,
|
|
3512
|
+
editableSummaryPreSubtotalDelta,
|
|
3513
|
+
editableSummaryPreSubtotalDebugRows,
|
|
3514
|
+
onLineAmountInputChange: handleEditableLineAmountInputChange,
|
|
3515
|
+
onLineAmountInputBlur: handleEditableLineAmountInputBlur,
|
|
3516
|
+
onLineAmountReset: handleEditableLineAmountReset,
|
|
3517
|
+
} = useEditableSummaryLines(
|
|
3518
|
+
checkoutPriceSummaryLinesForCheckout,
|
|
3519
|
+
{
|
|
3520
|
+
enabled: showProviderPricingInlineEditor,
|
|
3521
|
+
onChange: providerPricingUi?.onLineAmountInputChange,
|
|
3522
|
+
onBlur: providerPricingUi?.onLineAmountInputBlur,
|
|
3523
|
+
onReset: providerPricingUi?.onLineAmountReset,
|
|
3524
|
+
},
|
|
3525
|
+
);
|
|
3334
3526
|
/**
|
|
3335
3527
|
* FE cart rollup for line math, breakdowns, and `clientProposedTotal` hint to the API. Self-serve **footer** totals
|
|
3336
3528
|
* prefer `latestChangeQuote` once quote succeeds — this value is not an alternate source of truth for checkout.
|
|
@@ -3574,6 +3766,7 @@ export function AdminChangeBookingFlow({
|
|
|
3574
3766
|
hasOperationalChangesFromInitial: false,
|
|
3575
3767
|
dateChanged: false,
|
|
3576
3768
|
ticketsChanged: false,
|
|
3769
|
+
productChanged: false,
|
|
3577
3770
|
optionChanged: false,
|
|
3578
3771
|
pickupChanged: false,
|
|
3579
3772
|
countsChanged: false,
|
|
@@ -3587,6 +3780,7 @@ export function AdminChangeBookingFlow({
|
|
|
3587
3780
|
hasOperationalChangesFromInitial: false,
|
|
3588
3781
|
dateChanged: false,
|
|
3589
3782
|
ticketsChanged: false,
|
|
3783
|
+
productChanged: false,
|
|
3590
3784
|
optionChanged: false,
|
|
3591
3785
|
pickupChanged: false,
|
|
3592
3786
|
countsChanged: false,
|
|
@@ -3605,6 +3799,7 @@ export function AdminChangeBookingFlow({
|
|
|
3605
3799
|
const optionChanged = Boolean(
|
|
3606
3800
|
selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
|
|
3607
3801
|
);
|
|
3802
|
+
const productChanged = selectedParentProductChanged;
|
|
3608
3803
|
const normalizePickupId = (value: string | null | undefined) => {
|
|
3609
3804
|
const trimmed = value?.trim();
|
|
3610
3805
|
return trimmed ? trimmed : null;
|
|
@@ -3678,6 +3873,7 @@ export function AdminChangeBookingFlow({
|
|
|
3678
3873
|
return {
|
|
3679
3874
|
hasChangesFromInitial:
|
|
3680
3875
|
dateChanged ||
|
|
3876
|
+
productChanged ||
|
|
3681
3877
|
optionChanged ||
|
|
3682
3878
|
pickupChanged ||
|
|
3683
3879
|
countsChanged ||
|
|
@@ -3687,13 +3883,15 @@ export function AdminChangeBookingFlow({
|
|
|
3687
3883
|
// ignore option-id noise and only consider user-visible booking deltas.
|
|
3688
3884
|
hasOperationalChangesFromInitial:
|
|
3689
3885
|
dateChanged ||
|
|
3886
|
+
productChanged ||
|
|
3690
3887
|
pickupChanged ||
|
|
3691
3888
|
countsChanged ||
|
|
3692
3889
|
addOnsChanged ||
|
|
3693
3890
|
returnChanged,
|
|
3694
3891
|
dateChanged,
|
|
3695
3892
|
// Tickets line corresponds to "option + ticket counts"; add-ons and pickup changes affect itinerary but not the ticket label.
|
|
3696
|
-
ticketsChanged: Boolean(optionChanged || countsChanged),
|
|
3893
|
+
ticketsChanged: Boolean(productChanged || optionChanged || countsChanged),
|
|
3894
|
+
productChanged,
|
|
3697
3895
|
optionChanged,
|
|
3698
3896
|
pickupChanged,
|
|
3699
3897
|
countsChanged,
|
|
@@ -3702,6 +3900,7 @@ export function AdminChangeBookingFlow({
|
|
|
3702
3900
|
};
|
|
3703
3901
|
}, [
|
|
3704
3902
|
initialValues,
|
|
3903
|
+
selectedParentProductChanged,
|
|
3705
3904
|
changeFlowResolvedInitialProductOptionId,
|
|
3706
3905
|
selectedAvailability,
|
|
3707
3906
|
selectedReturnOption,
|
|
@@ -3726,6 +3925,7 @@ export function AdminChangeBookingFlow({
|
|
|
3726
3925
|
const changeQuoteInputsKey = useMemo(() => JSON.stringify({
|
|
3727
3926
|
bookingReference: initialValues?.bookingReference?.trim() ?? '',
|
|
3728
3927
|
lastName: lastName.trim().toLowerCase(),
|
|
3928
|
+
parentProductId: product.productId,
|
|
3729
3929
|
optionId: selectedAvailability?.productOptionId?.trim() || activeOptions[0]?.optionId || '',
|
|
3730
3930
|
dateTime: selectedAvailability?.dateTime ?? '',
|
|
3731
3931
|
availabilityId: selectedAvailability?.availabilityId ?? null,
|
|
@@ -3740,6 +3940,7 @@ export function AdminChangeBookingFlow({
|
|
|
3740
3940
|
}), [
|
|
3741
3941
|
initialValues?.bookingReference,
|
|
3742
3942
|
lastName,
|
|
3943
|
+
product.productId,
|
|
3743
3944
|
selectedAvailability?.productOptionId,
|
|
3744
3945
|
selectedAvailability?.dateTime,
|
|
3745
3946
|
selectedAvailability?.availabilityId,
|
|
@@ -3753,8 +3954,8 @@ export function AdminChangeBookingFlow({
|
|
|
3753
3954
|
editableSummaryLineLabelInputs,
|
|
3754
3955
|
useAdminFeAuthoritativeQuote,
|
|
3755
3956
|
]);
|
|
3756
|
-
const
|
|
3757
|
-
const missingRequiredReturnSelection =
|
|
3957
|
+
const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
|
|
3958
|
+
const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
|
|
3758
3959
|
|
|
3759
3960
|
const changeFlowSubmitDisabled =
|
|
3760
3961
|
missingRequiredReturnSelection ||
|
|
@@ -3912,9 +4113,14 @@ export function AdminChangeBookingFlow({
|
|
|
3912
4113
|
let subtotal = roundMoney(displayChangeFlowSubtotal + editableSummaryPreSubtotalDelta);
|
|
3913
4114
|
const tax = roundMoney(displayChangeFlowTax);
|
|
3914
4115
|
const expectedTotal = roundMoney(displayChangeFlowProposedTotalWithEditableLines);
|
|
3915
|
-
const
|
|
4116
|
+
const promoAdjustmentTotal = roundMoney(
|
|
4117
|
+
lineItems.reduce((sum, line) => (
|
|
4118
|
+
isPromoDiscountGiftCardOrVoucherLine(line) ? sum + (Number(line.amount) || 0) : sum
|
|
4119
|
+
), 0)
|
|
4120
|
+
);
|
|
4121
|
+
const recomputed = roundMoney(subtotal + tax + promoAdjustmentTotal);
|
|
3916
4122
|
if (Math.abs(recomputed - expectedTotal) >= 0.005) {
|
|
3917
|
-
subtotal = roundMoney(expectedTotal - tax);
|
|
4123
|
+
subtotal = roundMoney(expectedTotal - tax - promoAdjustmentTotal);
|
|
3918
4124
|
}
|
|
3919
4125
|
return {
|
|
3920
4126
|
subtotal,
|
|
@@ -4104,7 +4310,7 @@ export function AdminChangeBookingFlow({
|
|
|
4104
4310
|
|
|
4105
4311
|
const checkoutFormError =
|
|
4106
4312
|
(error || '') ||
|
|
4107
|
-
(missingRequiredReturnSelection ? '
|
|
4313
|
+
(missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
|
|
4108
4314
|
(isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
4109
4315
|
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
4110
4316
|
|
|
@@ -4317,6 +4523,7 @@ export function AdminChangeBookingFlow({
|
|
|
4317
4523
|
bookingReference: bookingReferenceForQuote,
|
|
4318
4524
|
lastName: lastName.trim(),
|
|
4319
4525
|
newProductId: optionId,
|
|
4526
|
+
newParentProductId: product.productId,
|
|
4320
4527
|
newDateTime: selectedAvailability.dateTime,
|
|
4321
4528
|
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
4322
4529
|
newPickupLocationId: pickupLocationId || null,
|
|
@@ -4588,6 +4795,21 @@ export function AdminChangeBookingFlow({
|
|
|
4588
4795
|
initialValues?.returnDateTime,
|
|
4589
4796
|
]);
|
|
4590
4797
|
|
|
4798
|
+
useEffect(() => {
|
|
4799
|
+
if (!selectedReturnOption) return;
|
|
4800
|
+
const returnOptions = selectedAvailability?.returnOptions ?? [];
|
|
4801
|
+
if (returnOptions.length === 0) {
|
|
4802
|
+
setSelectedReturnOption(null);
|
|
4803
|
+
return;
|
|
4804
|
+
}
|
|
4805
|
+
const updatedReturnOption = returnOptions.find(
|
|
4806
|
+
(opt) => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId,
|
|
4807
|
+
);
|
|
4808
|
+
if (!updatedReturnOption) {
|
|
4809
|
+
setSelectedReturnOption(null);
|
|
4810
|
+
}
|
|
4811
|
+
}, [selectedAvailability?.returnOptions, selectedReturnOption]);
|
|
4812
|
+
|
|
4591
4813
|
// Fetch add-ons when availability (product option) is selected; clear selections when option changes
|
|
4592
4814
|
const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
|
|
4593
4815
|
const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
|
|
@@ -4856,7 +5078,7 @@ export function AdminChangeBookingFlow({
|
|
|
4856
5078
|
return;
|
|
4857
5079
|
}
|
|
4858
5080
|
if (missingRequiredReturnSelection) {
|
|
4859
|
-
setError('
|
|
5081
|
+
setError('Please select a return time for this product.');
|
|
4860
5082
|
return;
|
|
4861
5083
|
}
|
|
4862
5084
|
|
|
@@ -4939,6 +5161,7 @@ export function AdminChangeBookingFlow({
|
|
|
4939
5161
|
bookingReference: changeBookingReference,
|
|
4940
5162
|
lastName: changeLastName,
|
|
4941
5163
|
newProductId: availabilityProductOptionId,
|
|
5164
|
+
newParentProductId: product.productId,
|
|
4942
5165
|
newDateTime: selectedAvailability.dateTime,
|
|
4943
5166
|
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
4944
5167
|
newPickupLocationId: pickupLocationId || null,
|
|
@@ -5053,6 +5276,7 @@ export function AdminChangeBookingFlow({
|
|
|
5053
5276
|
: null;
|
|
5054
5277
|
await onChangeBooking({
|
|
5055
5278
|
productId: availabilityProductOptionId,
|
|
5279
|
+
parentProductId: product.productId,
|
|
5056
5280
|
dateTime: selectedAvailability.dateTime,
|
|
5057
5281
|
bookingItems,
|
|
5058
5282
|
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
@@ -5222,7 +5446,7 @@ export function AdminChangeBookingFlow({
|
|
|
5222
5446
|
{
|
|
5223
5447
|
label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || (t('booking.discount') || 'Discount')),
|
|
5224
5448
|
amount: -effectivePromoDiscountAmount,
|
|
5225
|
-
type:
|
|
5449
|
+
type: effectivePromoLineType,
|
|
5226
5450
|
},
|
|
5227
5451
|
]
|
|
5228
5452
|
: []),
|
|
@@ -5448,6 +5672,7 @@ export function AdminChangeBookingFlow({
|
|
|
5448
5672
|
: null;
|
|
5449
5673
|
await onChangeBooking({
|
|
5450
5674
|
productId: availabilityProductOptionId,
|
|
5675
|
+
parentProductId: product.productId,
|
|
5451
5676
|
dateTime: selectedAvailability.dateTime,
|
|
5452
5677
|
bookingItems,
|
|
5453
5678
|
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
@@ -5645,6 +5870,40 @@ export function AdminChangeBookingFlow({
|
|
|
5645
5870
|
)}
|
|
5646
5871
|
{isPartialLaunch ? null : (
|
|
5647
5872
|
<div className="booking-calendar-section">
|
|
5873
|
+
{isAdmin && !isInitialPrivateShuttleBooking && availableChangeProducts.length > 1 ? (
|
|
5874
|
+
<div className="mb-6 rounded-lg border border-stone-200 bg-stone-50 p-4">
|
|
5875
|
+
<label
|
|
5876
|
+
className="mb-2 block text-sm font-medium text-stone-800"
|
|
5877
|
+
htmlFor="admin-change-product-select"
|
|
5878
|
+
>
|
|
5879
|
+
Product
|
|
5880
|
+
</label>
|
|
5881
|
+
<select
|
|
5882
|
+
id="admin-change-product-select"
|
|
5883
|
+
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"
|
|
5884
|
+
value={selectedChangeProductId}
|
|
5885
|
+
disabled={changeProductsLoading || loading || showCheckoutModal || showAdminPaymentChoice}
|
|
5886
|
+
onChange={(e) => {
|
|
5887
|
+
setSelectedChangeProductId(e.target.value);
|
|
5888
|
+
setSelectedDate('');
|
|
5889
|
+
setImplicitReturnBaselineId(null);
|
|
5890
|
+
changeFlowOutboundAnchorRef.current = null;
|
|
5891
|
+
changeFlowReturnAnchorRef.current = null;
|
|
5892
|
+
}}
|
|
5893
|
+
>
|
|
5894
|
+
{availableChangeProducts.map((p) => (
|
|
5895
|
+
<option key={p.productId} value={p.productId}>
|
|
5896
|
+
{p.name}
|
|
5897
|
+
</option>
|
|
5898
|
+
))}
|
|
5899
|
+
</select>
|
|
5900
|
+
{changeProductsError ? (
|
|
5901
|
+
<div className="mt-2 text-xs text-amber-800">
|
|
5902
|
+
Product list could not be refreshed. Showing the current product only.
|
|
5903
|
+
</div>
|
|
5904
|
+
) : null}
|
|
5905
|
+
</div>
|
|
5906
|
+
) : null}
|
|
5648
5907
|
{loadingAvailabilities && availabilities.length === 0 ? (
|
|
5649
5908
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
5650
5909
|
<div className="booking-loading-spinner" aria-hidden />
|
|
@@ -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). */
|