@ticketboothapp/booking 1.2.96 → 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.96",
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
 
@@ -4317,6 +4441,7 @@ export function AdminChangeBookingFlow({
4317
4441
  bookingReference: bookingReferenceForQuote,
4318
4442
  lastName: lastName.trim(),
4319
4443
  newProductId: optionId,
4444
+ newParentProductId: product.productId,
4320
4445
  newDateTime: selectedAvailability.dateTime,
4321
4446
  newAvailabilityId: selectedAvailability.availabilityId || null,
4322
4447
  newPickupLocationId: pickupLocationId || null,
@@ -4588,6 +4713,21 @@ export function AdminChangeBookingFlow({
4588
4713
  initialValues?.returnDateTime,
4589
4714
  ]);
4590
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
+
4591
4731
  // Fetch add-ons when availability (product option) is selected; clear selections when option changes
4592
4732
  const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
4593
4733
  const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
@@ -4856,7 +4996,7 @@ export function AdminChangeBookingFlow({
4856
4996
  return;
4857
4997
  }
4858
4998
  if (missingRequiredReturnSelection) {
4859
- setError('Removing return option in self-serve is not available. Please contact support.');
4999
+ setError('Please select a return time for this product.');
4860
5000
  return;
4861
5001
  }
4862
5002
 
@@ -4939,6 +5079,7 @@ export function AdminChangeBookingFlow({
4939
5079
  bookingReference: changeBookingReference,
4940
5080
  lastName: changeLastName,
4941
5081
  newProductId: availabilityProductOptionId,
5082
+ newParentProductId: product.productId,
4942
5083
  newDateTime: selectedAvailability.dateTime,
4943
5084
  newAvailabilityId: selectedAvailability.availabilityId || null,
4944
5085
  newPickupLocationId: pickupLocationId || null,
@@ -5053,6 +5194,7 @@ export function AdminChangeBookingFlow({
5053
5194
  : null;
5054
5195
  await onChangeBooking({
5055
5196
  productId: availabilityProductOptionId,
5197
+ parentProductId: product.productId,
5056
5198
  dateTime: selectedAvailability.dateTime,
5057
5199
  bookingItems,
5058
5200
  returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
@@ -5448,6 +5590,7 @@ export function AdminChangeBookingFlow({
5448
5590
  : null;
5449
5591
  await onChangeBooking({
5450
5592
  productId: availabilityProductOptionId,
5593
+ parentProductId: product.productId,
5451
5594
  dateTime: selectedAvailability.dateTime,
5452
5595
  bookingItems,
5453
5596
  returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
@@ -5645,6 +5788,40 @@ export function AdminChangeBookingFlow({
5645
5788
  )}
5646
5789
  {isPartialLaunch ? null : (
5647
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}
5648
5825
  {loadingAvailabilities && availabilities.length === 0 ? (
5649
5826
  <div className="flex flex-col items-center justify-center py-12 gap-4">
5650
5827
  <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). */