@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.
@@ -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.95",
3
+ "version": "1.2.97",
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,
@@ -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 requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
3757
- const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
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 ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
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
- ...providerAdditionalAdjustments,
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('Removing return option in self-serve is not available. Please contact support.');
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 }>;
@@ -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). */