@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.96",
3
+ "version": "1.2.98",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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((originalReceipt?.promoAmount ?? 0) * 100)) : '',
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
- originalReceipt?.promoAmount,
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: originalReceipt?.promoAmount }
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, originalReceipt?.promoAmount ?? 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 && !isGiftCard && !isVoucher
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 requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
3757
- const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
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 recomputed = roundMoney(subtotal + tax);
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 ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
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('Removing return option in self-serve is not available. Please contact support.');
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: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
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 }>;
@@ -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). */