@ticketboothapp/booking 1.2.166 → 1.2.168

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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  7. package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
  8. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  9. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  10. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  11. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  12. package/src/components/booking/NewBookingFlow.tsx +14 -2
  13. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  14. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  15. package/src/components/booking/PriceBreakdown.tsx +66 -15
  16. package/src/components/booking/PriceSummary.tsx +16 -0
  17. package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
  18. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  19. package/src/components/booking/admin-adjustment-components.ts +44 -0
  20. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  21. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  22. package/src/components/booking/admin-change-provider-payload.ts +12 -1
  23. package/src/components/booking/admin-change-quote-request-key.ts +41 -1
  24. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  25. package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
  26. package/src/components/booking/admin-refund-decision-context.ts +122 -0
  27. package/src/components/booking/admin-refund-disposition.ts +71 -2
  28. package/src/components/booking/availability-date-selection.ts +16 -0
  29. package/src/components/booking/booking-flow-types.ts +4 -0
  30. package/src/components/booking/booking-flow.css +5 -0
  31. package/src/components/booking/change-booking-error-message.ts +137 -0
  32. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  33. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  34. package/src/components/booking/change-booking-quote-state.ts +44 -1
  35. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  36. package/src/components/booking/private-shuttle-availability.ts +14 -0
  37. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  38. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  39. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  40. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  41. package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
  42. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  43. package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
  44. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  45. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  46. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
  47. package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
  48. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  49. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  50. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  51. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  52. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  53. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  54. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  55. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  56. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  57. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  58. package/src/lib/booking/booking-cutoffs.ts +22 -0
  59. package/src/lib/booking/change-booking-server-preview.ts +92 -5
  60. package/src/lib/booking/change-flow-pricing.ts +12 -0
  61. package/src/lib/booking/i18n/messages/en.json +1 -0
  62. package/src/lib/booking/i18n/messages/fr.json +1 -0
  63. package/src/lib/booking-api.ts +76 -1
  64. package/test/change-booking-helpers.test.ts +1319 -7
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -5,17 +5,8 @@ import type {
5
5
  ProviderDashboardPricingLine,
6
6
  } from './booking-flow-ui';
7
7
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
8
+ import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
8
9
 
9
- type AdminCustomReceiptLine = {
10
- id: string;
11
- label: string;
12
- amountInput: string;
13
- amountSign?: number;
14
- calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
15
- scope: 'POSITIVE_AMENDMENT_VALUE' | 'CURRENT_ACTIVE_TICKETS' | 'CURRENT_ACTIVE_BOOKING';
16
- taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
17
- reasonCode: string;
18
- };
19
10
  type AdminStructuredAdjustment = NonNullable<
20
11
  ProviderDashboardChangeBookingPayload['structuredAdjustments']
21
12
  >[number];
@@ -26,6 +17,35 @@ type ProviderAdditionalAdjustment = NonNullable<
26
17
  NonNullable<ProviderDashboardChangeBookingPayload['pricingAdjustment']>['additionalAdjustments']
27
18
  >[number];
28
19
 
20
+ export interface EffectiveAdminChangeSelectionInput {
21
+ hasChangeSelection: boolean;
22
+ providerPricingOverrideCount: number;
23
+ providerHasAdditionalAdjustments: boolean;
24
+ isProviderDashboardChange: boolean;
25
+ editableSummaryPreSubtotalDelta: number;
26
+ adminStructuredAdjustmentCount: number;
27
+ adminReverseAdjustmentCount?: number;
28
+ }
29
+
30
+ export function hasEffectiveAdminChangeSelection({
31
+ hasChangeSelection,
32
+ providerPricingOverrideCount,
33
+ providerHasAdditionalAdjustments,
34
+ isProviderDashboardChange,
35
+ editableSummaryPreSubtotalDelta,
36
+ adminStructuredAdjustmentCount,
37
+ adminReverseAdjustmentCount = 0,
38
+ }: EffectiveAdminChangeSelectionInput): boolean {
39
+ return Boolean(
40
+ hasChangeSelection ||
41
+ providerPricingOverrideCount > 0 ||
42
+ providerHasAdditionalAdjustments ||
43
+ (isProviderDashboardChange && Math.abs(editableSummaryPreSubtotalDelta) >= 0.005) ||
44
+ adminStructuredAdjustmentCount > 0 ||
45
+ adminReverseAdjustmentCount > 0,
46
+ );
47
+ }
48
+
29
49
  export interface UseAdminProviderPricingAdjustmentsParams {
30
50
  isProviderDashboardChange: boolean;
31
51
  showProviderPricingInlineEditor: boolean;
@@ -33,8 +53,8 @@ export interface UseAdminProviderPricingAdjustmentsParams {
33
53
  providerQuotedLines: ProviderDashboardPricingLine[];
34
54
  hasChangeSelection: boolean;
35
55
  editableSummaryPreSubtotalDelta: number;
36
- adminCustomAdjustmentTotal: number;
37
56
  adminCustomReceiptLines: AdminCustomReceiptLine[];
57
+ adminReverseAdjustmentIds: string[];
38
58
  isManualOverrideEligibleLine: (line: ProviderDashboardPricingLine) => boolean;
39
59
  }
40
60
 
@@ -51,8 +71,8 @@ export function useAdminProviderPricingAdjustments({
51
71
  providerQuotedLines,
52
72
  hasChangeSelection,
53
73
  editableSummaryPreSubtotalDelta,
54
- adminCustomAdjustmentTotal,
55
74
  adminCustomReceiptLines,
75
+ adminReverseAdjustmentIds,
56
76
  isManualOverrideEligibleLine,
57
77
  }: UseAdminProviderPricingAdjustmentsParams) {
58
78
  const [adjustmentIdNamespace] = useState(() =>
@@ -127,6 +147,7 @@ export function useAdminProviderPricingAdjustments({
127
147
  const adminCustomLinesAsAdditionalAdjustments = useMemo(
128
148
  (): ProviderAdditionalAdjustment[] =>
129
149
  adminCustomReceiptLines.flatMap((line) => {
150
+ if (line.sourceAdjustmentId) return [];
130
151
  const raw = line.amountInput.trim();
131
152
  if (raw === '' || raw === '+' || raw === '-') return [];
132
153
  const n = Number(raw);
@@ -142,20 +163,26 @@ export function useAdminProviderPricingAdjustments({
142
163
  const adminStructuredAdjustments = useMemo(
143
164
  (): AdminStructuredAdjustment[] => {
144
165
  const customAdjustments = adminCustomReceiptLines.flatMap((line) => {
166
+ if (line.sourceAdjustmentId && !line.isDirty) return [];
145
167
  const value = Number(line.amountInput.trim());
146
168
  if (!Number.isFinite(value) || value <= 0) return [];
169
+ if (line.scope === 'SELECTED_COMPONENTS' && line.selectedComponentIds.length === 0) {
170
+ return [];
171
+ }
147
172
  const discount = (line.amountSign ?? 1) < 0;
148
173
  const percentage = line.calculation === 'PERCENTAGE';
149
174
  const mode: AdminStructuredAdjustment['mode'] = percentage
150
175
  ? discount ? 'PERCENTAGE_DISCOUNT' : 'PERCENTAGE_SURCHARGE'
151
176
  : 'FIXED_AMOUNT';
152
177
  return [{
153
- adjustmentId: line.id,
178
+ adjustmentId: line.replacementAdjustmentId ?? line.id,
154
179
  mode,
155
180
  ...(percentage
156
181
  ? { percentageBasisPoints: Math.round(value * 100) }
157
182
  : { fixedAmount: roundMoney((discount ? -1 : 1) * value) }),
158
183
  scope: line.scope,
184
+ selectedComponentIds:
185
+ line.scope === 'SELECTED_COMPONENTS' ? line.selectedComponentIds : [],
159
186
  taxBehavior: line.taxBehavior,
160
187
  reasonCode: line.reasonCode.trim() || 'STAFF_ADJUSTMENT',
161
188
  reason: line.label.trim() || null,
@@ -209,12 +236,15 @@ export function useAdminProviderPricingAdjustments({
209
236
  [providerAdditionalAdjustments],
210
237
  );
211
238
 
212
- const hasEffectiveChangeSelection =
213
- hasChangeSelection ||
214
- providerPricingOverrides.length > 0 ||
215
- providerHasAdditionalAdjustments ||
216
- (isProviderDashboardChange && Math.abs(editableSummaryPreSubtotalDelta) >= 0.005) ||
217
- Math.abs(adminCustomAdjustmentTotal) >= 0.005;
239
+ const hasEffectiveChangeSelection = hasEffectiveAdminChangeSelection({
240
+ hasChangeSelection,
241
+ providerPricingOverrideCount: providerPricingOverrides.length,
242
+ providerHasAdditionalAdjustments,
243
+ isProviderDashboardChange,
244
+ editableSummaryPreSubtotalDelta,
245
+ adminStructuredAdjustmentCount: adminStructuredAdjustments.length,
246
+ adminReverseAdjustmentCount: adminReverseAdjustmentIds.length,
247
+ });
218
248
 
219
249
  return {
220
250
  providerTotalsPreview,
@@ -223,5 +253,6 @@ export function useAdminProviderPricingAdjustments({
223
253
  adminCustomLinesAsAdditionalAdjustments,
224
254
  mergedProviderAdditionalAdjustments,
225
255
  adminStructuredAdjustments,
256
+ adminReverseAdjustmentIds,
226
257
  };
227
258
  }
@@ -16,6 +16,7 @@ export interface UseBookingAvailabilityAddOnsResult {
16
16
  addOns: AddOn[];
17
17
  setAddOns: Dispatch<SetStateAction<AddOn[]>>;
18
18
  availabilityProductOptionId: string | null;
19
+ loadedProductOptionId: string | null;
19
20
  }
20
21
 
21
22
  export function useBookingAvailabilityAddOns({
@@ -24,15 +25,19 @@ export function useBookingAvailabilityAddOns({
24
25
  onProductOptionChange,
25
26
  }: UseBookingAvailabilityAddOnsParams): UseBookingAvailabilityAddOnsResult {
26
27
  const [addOns, setAddOns] = useState<AddOn[]>([]);
28
+ const [loadedProductOptionId, setLoadedProductOptionId] = useState<string | null>(null);
27
29
  const previousAvailabilityProductOptionIdRef = useRef<string | null>(null);
28
30
  const availabilityProductOptionId = useMemo(
29
31
  () => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null),
30
32
  [selectedAvailability],
31
33
  );
34
+ const availabilityDateTime = selectedAvailability?.dateTime?.trim() || null;
32
35
 
33
36
  useEffect(() => {
37
+ let cancelled = false;
34
38
  if (!availabilityProductOptionId || !companyId) {
35
39
  setAddOns([]);
40
+ setLoadedProductOptionId(null);
36
41
  onProductOptionChange?.();
37
42
  return;
38
43
  }
@@ -41,14 +46,31 @@ export function useBookingAvailabilityAddOns({
41
46
  onProductOptionChange?.();
42
47
  previousAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
43
48
  }
44
- getAddOns(companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
45
- .then(setAddOns)
46
- .catch(() => setAddOns([]));
47
- }, [availabilityProductOptionId, companyId, onProductOptionChange]);
49
+ setLoadedProductOptionId(null);
50
+ getAddOns(companyId, {
51
+ productOptionId: availabilityProductOptionId,
52
+ preCheckout: true,
53
+ dateTime: availabilityDateTime ?? undefined,
54
+ })
55
+ .then((rows) => {
56
+ if (cancelled) return;
57
+ setAddOns(rows);
58
+ setLoadedProductOptionId(availabilityProductOptionId);
59
+ })
60
+ .catch(() => {
61
+ if (cancelled) return;
62
+ setAddOns([]);
63
+ setLoadedProductOptionId(availabilityProductOptionId);
64
+ });
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, [availabilityProductOptionId, availabilityDateTime, companyId, onProductOptionChange]);
48
69
 
49
70
  return {
50
71
  addOns,
51
72
  setAddOns,
52
73
  availabilityProductOptionId,
74
+ loadedProductOptionId,
53
75
  };
54
76
  }
@@ -27,6 +27,9 @@ export interface UseBookingPromoAndQuantityControllerParams {
27
27
  export interface UseBookingPromoAndQuantityControllerResult {
28
28
  handleQuantityChange: (category: string, delta: number) => void;
29
29
  handleApplyPromo: () => Promise<void>;
30
+ handleRemovePromo: () => void;
31
+ promoCodeError: string;
32
+ promoCodeValidating: boolean;
30
33
  }
31
34
 
32
35
  export function useBookingPromoAndQuantityController({
@@ -47,8 +50,8 @@ export function useBookingPromoAndQuantityController({
47
50
  t,
48
51
  setError,
49
52
  }: UseBookingPromoAndQuantityControllerParams): UseBookingPromoAndQuantityControllerResult {
50
- const [, setPromoCodeError] = useState('');
51
- const [, setPromoCodeValidating] = useState(false);
53
+ const [promoCodeError, setPromoCodeError] = useState('');
54
+ const [promoCodeValidating, setPromoCodeValidating] = useState(false);
52
55
  const promoAppliedSelectionKeyRef = useRef<string | null>(null);
53
56
  const promoValidateInFlightRef = useRef(false);
54
57
 
@@ -156,8 +159,19 @@ export function useBookingPromoAndQuantityController({
156
159
  return () => clearTimeout(timer);
157
160
  }, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
158
161
 
162
+ const handleRemovePromo = useCallback(() => {
163
+ promoAppliedSelectionKeyRef.current = null;
164
+ setAppliedPromoCode(null);
165
+ setPromoCodeError('');
166
+ setPromoCodeValidating(false);
167
+ clearFetchedAvailabilityRanges();
168
+ }, [clearFetchedAvailabilityRanges, setAppliedPromoCode]);
169
+
159
170
  return {
160
171
  handleQuantityChange,
161
172
  handleApplyPromo,
173
+ handleRemovePromo,
174
+ promoCodeError,
175
+ promoCodeValidating,
162
176
  };
163
177
  }
@@ -13,7 +13,7 @@ interface UseChangeBookingAddOnFloorControllerOptions {
13
13
  initialValues?: ChangeBookingFlowProps['initialValues'];
14
14
  originalReceiptLineItems?: NonNullable<ChangeBookingFlowProps['originalReceipt']>['lineItems'];
15
15
  addOns: AddOn[];
16
- isCustomerSelfServeChange: boolean;
16
+ lockExistingAddOnQuantities: boolean;
17
17
  setAddOnSelections: Dispatch<SetStateAction<AddOnSelection[]>>;
18
18
  }
19
19
 
@@ -21,9 +21,10 @@ export function useChangeBookingAddOnFloorController({
21
21
  initialValues,
22
22
  originalReceiptLineItems,
23
23
  addOns,
24
- isCustomerSelfServeChange,
24
+ lockExistingAddOnQuantities,
25
25
  setAddOnSelections,
26
26
  }: UseChangeBookingAddOnFloorControllerOptions): {
27
+ initialAddOnQtyByKey: Map<string, number>;
27
28
  initialAddOnMinQtyByKey: Map<string, number>;
28
29
  initialAddOnMinTotalByAddOnId: Map<string, number>;
29
30
  updateAddOnSelections: (updater: AddOnSelectionsUpdater) => void;
@@ -39,29 +40,33 @@ export function useChangeBookingAddOnFloorController({
39
40
  return [] as AddOnSelection[];
40
41
  }, [initialValues?.addOnSelections, originalReceiptLineItems, addOns]);
41
42
 
42
- const initialAddOnMinQtyByKey = useMemo(() => {
43
+ const initialAddOnQtyByKey = useMemo(() => {
43
44
  const map = new Map<string, number>();
44
- if (!isCustomerSelfServeChange) return map;
45
45
  for (const sel of initialAddOnBaselineSelections) {
46
46
  const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
47
47
  map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
48
48
  }
49
49
  return map;
50
- }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
50
+ }, [initialAddOnBaselineSelections]);
51
+
52
+ const initialAddOnMinQtyByKey = useMemo(
53
+ () => (lockExistingAddOnQuantities ? initialAddOnQtyByKey : new Map<string, number>()),
54
+ [lockExistingAddOnQuantities, initialAddOnQtyByKey]
55
+ );
51
56
 
52
57
  const initialAddOnMinTotalByAddOnId = useMemo(() => {
53
58
  const map = new Map<string, number>();
54
- if (!isCustomerSelfServeChange) return map;
59
+ if (!lockExistingAddOnQuantities) return map;
55
60
  for (const sel of initialAddOnBaselineSelections) {
56
61
  const addOnId = sel.addOnId.trim();
57
62
  map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
58
63
  }
59
64
  return map;
60
- }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
65
+ }, [lockExistingAddOnQuantities, initialAddOnBaselineSelections]);
61
66
 
62
67
  const applyChangeFlowAddOnFloor = useCallback(
63
68
  (nextSelections: AddOnSelection[]) => {
64
- if (!isCustomerSelfServeChange || initialAddOnMinQtyByKey.size === 0) return nextSelections;
69
+ if (!lockExistingAddOnQuantities || initialAddOnMinQtyByKey.size === 0) return nextSelections;
65
70
  const qtyByKey = new Map<string, number>();
66
71
  for (const sel of nextSelections) {
67
72
  const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
@@ -88,7 +93,7 @@ export function useChangeBookingAddOnFloorController({
88
93
  return { addOnId, variantId, quantity: qty };
89
94
  });
90
95
  },
91
- [isCustomerSelfServeChange, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
96
+ [lockExistingAddOnQuantities, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
92
97
  );
93
98
 
94
99
  const updateAddOnSelections = useCallback(
@@ -102,6 +107,7 @@ export function useChangeBookingAddOnFloorController({
102
107
  );
103
108
 
104
109
  return {
110
+ initialAddOnQtyByKey,
105
111
  initialAddOnMinQtyByKey,
106
112
  initialAddOnMinTotalByAddOnId,
107
113
  updateAddOnSelections,
@@ -1,7 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, type RefObject } from 'react';
3
+ import { useCallback, useEffect, useRef, type RefObject } from 'react';
4
4
  import type { Availability, PickupLocation } from '../../lib/booking-api';
5
+ import {
6
+ findFirstDateWithBookableAvailability,
7
+ selectedDateHasVisibleAvailability,
8
+ } from './availability-date-selection';
5
9
 
6
10
  export interface UseChangeBookingAutoSelectionsOptions {
7
11
  autoSelectFirstAvailableDate?: boolean;
@@ -71,6 +75,55 @@ export function useChangeBookingAutoSelections({
71
75
  const hasAutoSelectedPartnerDateRef = useRef(false);
72
76
  const hasAutoSelectedPartnerPickupRef = useRef(false);
73
77
 
78
+ const isBookableForAutoSelection = useCallback(
79
+ (availability: Availability): boolean => {
80
+ if (isAdmin) return (availability.vacancies ?? 0) > 0;
81
+ if (isCustomerSelfServeChange && changeFlowInitialTicketCount > 0) {
82
+ return getCalendarEffectiveOutboundVacancies(availability) >= changeFlowInitialTicketCount;
83
+ }
84
+ return (availability.vacancies ?? 0) > 0;
85
+ },
86
+ [
87
+ changeFlowInitialTicketCount,
88
+ getCalendarEffectiveOutboundVacancies,
89
+ isAdmin,
90
+ isCustomerSelfServeChange,
91
+ ],
92
+ );
93
+
94
+ useEffect(() => {
95
+ if (!autoSelectFirstAvailableDate) return;
96
+ if (isPartialLaunch) return;
97
+ if (!selectedDate || dates.length === 0) return;
98
+ if (selectedDateHasVisibleAvailability(selectedDate, getRowsForDate)) return;
99
+
100
+ const first = findFirstDateWithBookableAvailability(
101
+ dates,
102
+ getRowsForDate,
103
+ isBookableForAutoSelection,
104
+ );
105
+ if (!first || first === selectedDate) return;
106
+
107
+ hasAutoSelectedPartnerDateRef.current = true;
108
+ setSelectedDate(first);
109
+ onDateSelect(first);
110
+ if (!suppressCalendarDateScroll) {
111
+ scrollAfterCalendarSelection(contentRef, useWindowScroll);
112
+ }
113
+ }, [
114
+ autoSelectFirstAvailableDate,
115
+ isPartialLaunch,
116
+ selectedDate,
117
+ dates,
118
+ getRowsForDate,
119
+ isBookableForAutoSelection,
120
+ setSelectedDate,
121
+ onDateSelect,
122
+ suppressCalendarDateScroll,
123
+ contentRef,
124
+ useWindowScroll,
125
+ ]);
126
+
74
127
  useEffect(() => {
75
128
  if (!autoSelectFirstAvailableDate) return;
76
129
  if (isPartialLaunch) return;
@@ -79,18 +132,11 @@ export function useChangeBookingAutoSelections({
79
132
  if (dates.length === 0) return;
80
133
  if (hasAutoSelectedPartnerDateRef.current) return;
81
134
 
82
- const firstWithInventory = dates.find((date) => {
83
- const rows = getRowsForDate(date);
84
- if (rows.length === 0) return false;
85
- if (isAdmin) return rows.some((availability) => (availability.vacancies ?? 0) > 0);
86
- if (isCustomerSelfServeChange && changeFlowInitialTicketCount > 0) {
87
- return rows.some(
88
- (availability) =>
89
- getCalendarEffectiveOutboundVacancies(availability) >= changeFlowInitialTicketCount,
90
- );
91
- }
92
- return rows.some((availability) => (availability.vacancies ?? 0) > 0);
93
- });
135
+ const firstWithInventory = findFirstDateWithBookableAvailability(
136
+ dates,
137
+ getRowsForDate,
138
+ isBookableForAutoSelection,
139
+ );
94
140
  const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
95
141
  if (!first) return;
96
142
 
@@ -107,10 +153,7 @@ export function useChangeBookingAutoSelections({
107
153
  selectedDate,
108
154
  dates,
109
155
  getRowsForDate,
110
- isAdmin,
111
- isCustomerSelfServeChange,
112
- changeFlowInitialTicketCount,
113
- getCalendarEffectiveOutboundVacancies,
156
+ isBookableForAutoSelection,
114
157
  setSelectedDate,
115
158
  onDateSelect,
116
159
  suppressCalendarDateScroll,
@@ -6,6 +6,7 @@ import type { AddOn, Availability, PrecomputedPricesByCategory } from '../../lib
6
6
  import type { Currency } from './CurrencySwitcher';
7
7
  import type { ChangeBookingFlowProps } from './booking-flow-types';
8
8
  import {
9
+ deriveCompatibleInitialBookingQuantities,
9
10
  deriveAddOnSelectionsFromReceiptLines,
10
11
  normalizeAddOnSelections,
11
12
  parseAvailabilityDateTime,
@@ -23,6 +24,7 @@ export interface UseChangeBookingInitialHydrationOptions {
23
24
  timesForSelectedDate: Availability[];
24
25
  companyTimezone: string;
25
26
  changeFlowResolvedInitialProductOptionId: string | null;
27
+ mostPopularProductOptionId: string | null;
26
28
  precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined;
27
29
  currency: Currency;
28
30
  originalReceiptSubtotal?: number | null;
@@ -53,6 +55,7 @@ export function useChangeBookingInitialHydration({
53
55
  timesForSelectedDate,
54
56
  companyTimezone,
55
57
  changeFlowResolvedInitialProductOptionId,
58
+ mostPopularProductOptionId,
56
59
  precomputedPricesByOption,
57
60
  currency,
58
61
  originalReceiptSubtotal,
@@ -130,6 +133,7 @@ export function useChangeBookingInitialHydration({
130
133
  precomputedPricesByOption,
131
134
  currency,
132
135
  originalReceiptSubtotal ?? undefined,
136
+ mostPopularProductOptionId,
133
137
  );
134
138
  if (defer) return;
135
139
  if (selection) {
@@ -142,6 +146,7 @@ export function useChangeBookingInitialHydration({
142
146
  initialValues?.productOptionId,
143
147
  initialValues?.bookingItems,
144
148
  changeFlowResolvedInitialProductOptionId,
149
+ mostPopularProductOptionId,
145
150
  selectedAvailability,
146
151
  selectedDate,
147
152
  timesForSelectedDate,
@@ -163,15 +168,13 @@ export function useChangeBookingInitialHydration({
163
168
  ) {
164
169
  return;
165
170
  }
166
- const next: Record<string, number> = {};
167
- for (const item of initialValues.bookingItems) {
168
- const key = item.category?.trim();
169
- if (!key) continue;
170
- next[key] = Math.max(0, Number(item.count) || 0);
171
- }
171
+ const next = deriveCompatibleInitialBookingQuantities(
172
+ initialValues.bookingItems,
173
+ selectedAvailability,
174
+ );
175
+ hasAppliedInitialQuantitiesRef.current = true;
172
176
  if (Object.keys(next).length > 0) {
173
177
  setQuantities((prev) => ({ ...prev, ...next }));
174
- hasAppliedInitialQuantitiesRef.current = true;
175
178
  }
176
179
  }, [initialValues, selectedAvailability, setQuantities]);
177
180
 
@@ -24,6 +24,7 @@ import {
24
24
  formatTicketLineItemsForSummary,
25
25
  getAvailabilityOptionId,
26
26
  } from './change-booking-flow-helpers';
27
+ import { friendlyChangeBookingError } from './change-booking-error-message';
27
28
  import type { Currency } from './CurrencySwitcher';
28
29
 
29
30
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
@@ -197,11 +198,12 @@ export function useChangeBookingQuotePreview({
197
198
  : 'Change booking (no charge)';
198
199
  })();
199
200
 
200
- const checkoutFormError =
201
+ const rawCheckoutFormError =
201
202
  (error || '') ||
202
203
  (missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
203
204
  (isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
204
205
  (changeQuoteFetchError ?? '');
206
+ const checkoutFormError = friendlyChangeBookingError(rawCheckoutFormError);
205
207
 
206
208
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
207
209
  if (!selectedAvailability || totalQuantity <= 0) return null;
@@ -30,7 +30,7 @@ export interface UseChangeBookingSelectionDetailsParams {
30
30
  pickupLocationId: string | null;
31
31
  quantities: Record<string, number>;
32
32
  addOnSelections: AddOnSelection[];
33
- initialAddOnMinQtyByKey: Map<string, number>;
33
+ initialAddOnQtyByKey: Map<string, number>;
34
34
  changeFlowResolvedInitialProductOptionId: string | null;
35
35
  productChanged?: boolean;
36
36
  }
@@ -65,6 +65,25 @@ function normalizeCounts(items: Array<{ category: string; count: number }> | nul
65
65
  return map;
66
66
  }
67
67
 
68
+ export function haveAddOnSelectionsChanged(
69
+ initialAddOnQtyByKey: Map<string, number>,
70
+ addOnSelections: AddOnSelection[],
71
+ ): boolean {
72
+ const currentAddOnQtyByKey = new Map<string, number>();
73
+ for (const sel of addOnSelections) {
74
+ const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
75
+ currentAddOnQtyByKey.set(key, (currentAddOnQtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
76
+ }
77
+
78
+ const allAddOnKeys = new Set([...initialAddOnQtyByKey.keys(), ...currentAddOnQtyByKey.keys()]);
79
+ for (const key of allAddOnKeys) {
80
+ if ((initialAddOnQtyByKey.get(key) ?? 0) !== (currentAddOnQtyByKey.get(key) ?? 0)) {
81
+ return true;
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
68
87
  export function useChangeBookingSelectionDetails({
69
88
  initialValues,
70
89
  selectedAvailability,
@@ -73,7 +92,7 @@ export function useChangeBookingSelectionDetails({
73
92
  pickupLocationId,
74
93
  quantities,
75
94
  addOnSelections,
76
- initialAddOnMinQtyByKey,
95
+ initialAddOnQtyByKey,
77
96
  changeFlowResolvedInitialProductOptionId,
78
97
  productChanged = false,
79
98
  }: UseChangeBookingSelectionDetailsParams): ChangeBookingSelectionDetails {
@@ -115,20 +134,7 @@ export function useChangeBookingSelectionDetails({
115
134
  }
116
135
  }
117
136
 
118
- const currentAddOnQtyByKey = new Map<string, number>();
119
- for (const sel of addOnSelections) {
120
- const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
121
- currentAddOnQtyByKey.set(key, (currentAddOnQtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
122
- }
123
-
124
- const allAddOnKeys = new Set([...initialAddOnMinQtyByKey.keys(), ...currentAddOnQtyByKey.keys()]);
125
- let addOnsChanged = false;
126
- for (const key of allAddOnKeys) {
127
- if ((initialAddOnMinQtyByKey.get(key) ?? 0) !== (currentAddOnQtyByKey.get(key) ?? 0)) {
128
- addOnsChanged = true;
129
- break;
130
- }
131
- }
137
+ const addOnsChanged = haveAddOnSelectionsChanged(initialAddOnQtyByKey, addOnSelections);
132
138
 
133
139
  const hasReturnOptions = (selectedAvailability?.returnOptions?.length ?? 0) > 0;
134
140
  const initialReturnId = initialValues.returnAvailabilityId?.trim() || null;
@@ -193,7 +199,7 @@ export function useChangeBookingSelectionDetails({
193
199
  pickupLocationId,
194
200
  quantities,
195
201
  addOnSelections,
196
- initialAddOnMinQtyByKey,
202
+ initialAddOnQtyByKey,
197
203
  changeFlowResolvedInitialProductOptionId,
198
204
  productChanged,
199
205
  ]);
@@ -3,9 +3,14 @@ import { parseISO } from 'date-fns';
3
3
  import type { Availability, Product, ReturnOption } from '../../lib/booking-api';
4
4
  import {
5
5
  availabilityMatchesSelectedDate,
6
+ findMergedAvailabilityForSelection,
6
7
  parseAvailabilityDateTime,
7
8
  pickDefaultAvailabilityForTimes,
8
9
  } from './standard-booking-availability';
10
+ import {
11
+ findFirstDateWithBookableAvailability,
12
+ selectedDateHasVisibleAvailability,
13
+ } from './availability-date-selection';
9
14
 
10
15
  interface UseStandardBookingAutoSelectionsParams {
11
16
  selectedDate: string;
@@ -91,19 +96,22 @@ export function useStandardBookingAutoSelections({
91
96
 
92
97
  useEffect(() => {
93
98
  if (!selectedDate || timesForSelectedDateLength === 0) return;
99
+ const eligibleTimes = getPickupCutoffEligibleTimesForDate(selectedDate);
94
100
  if (
95
101
  selectedAvailability &&
96
- availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)
102
+ availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone) &&
103
+ findMergedAvailabilityForSelection(eligibleTimes, selectedAvailability)
97
104
  ) {
98
105
  return;
99
106
  }
100
- const toSelect = pickDefaultAvailabilityForTimes(
101
- getPickupCutoffEligibleTimesForDate(selectedDate),
102
- activeOptions,
103
- );
107
+ const toSelect = pickDefaultAvailabilityForTimes(eligibleTimes, activeOptions);
104
108
  if (toSelect) {
105
109
  setSelectedAvailability(toSelect);
110
+ setSelectedReturnOption(null);
106
111
  setError('');
112
+ } else if (selectedAvailability) {
113
+ setSelectedAvailability(null);
114
+ setSelectedReturnOption(null);
107
115
  }
108
116
  }, [
109
117
  selectedDate,
@@ -114,9 +122,47 @@ export function useStandardBookingAutoSelections({
114
122
  companyTimezone,
115
123
  getPickupCutoffEligibleTimesForDate,
116
124
  setSelectedAvailability,
125
+ setSelectedReturnOption,
117
126
  setError,
118
127
  ]);
119
128
 
129
+ useEffect(() => {
130
+ if (!autoSelectFirstAvailableDate) return;
131
+ if (isPartialLaunch) return;
132
+ if (!selectedDate || dates.length === 0) return;
133
+ if (selectedDateHasVisibleAvailability(selectedDate, getPickupCutoffEligibleTimesForDate)) return;
134
+
135
+ const first = findFirstDateWithBookableAvailability(
136
+ dates,
137
+ getPickupCutoffEligibleTimesForDate,
138
+ (availability) => (availability.vacancies ?? 0) > 0,
139
+ );
140
+ if (!first || first === selectedDate) return;
141
+
142
+ hasAutoSelectedPartnerDateRef.current = true;
143
+ setSelectedDate(first);
144
+ setSelectedReturnOption(null);
145
+ setSelectedAvailability(
146
+ pickDefaultAvailabilityForTimes(getPickupCutoffEligibleTimesForDate(first), activeOptions),
147
+ );
148
+ if (!suppressCalendarDateScroll) {
149
+ scrollAfterCalendarSelection(contentRef, useWindowScroll);
150
+ }
151
+ }, [
152
+ autoSelectFirstAvailableDate,
153
+ isPartialLaunch,
154
+ selectedDate,
155
+ dates,
156
+ getPickupCutoffEligibleTimesForDate,
157
+ activeOptions,
158
+ setSelectedAvailability,
159
+ setSelectedDate,
160
+ setSelectedReturnOption,
161
+ suppressCalendarDateScroll,
162
+ contentRef,
163
+ useWindowScroll,
164
+ ]);
165
+
120
166
  useEffect(() => {
121
167
  if (!selectedAvailability?.returnOptions?.length || selectedReturnOption) return;
122
168
 
@@ -162,8 +208,10 @@ export function useStandardBookingAutoSelections({
162
208
  if (dates.length === 0) return;
163
209
  if (hasAutoSelectedPartnerDateRef.current) return;
164
210
 
165
- const firstWithInventory = dates.find((date) =>
166
- getPickupCutoffEligibleTimesForDate(date).some((availability) => (availability.vacancies ?? 0) > 0),
211
+ const firstWithInventory = findFirstDateWithBookableAvailability(
212
+ dates,
213
+ getPickupCutoffEligibleTimesForDate,
214
+ (availability) => (availability.vacancies ?? 0) > 0,
167
215
  );
168
216
  const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
169
217
  if (!first) return;