@ticketboothapp/booking 1.2.187 → 1.2.189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.187",
3
+ "version": "1.2.189",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -897,6 +897,12 @@ export function AdminChangeBookingFlow({
897
897
  activeReturnPriceTreatmentOperations,
898
898
  refundDispositionsByOperationId,
899
899
  ]);
900
+ const initialSelectionHydratedForQuote = Boolean(
901
+ selectedAvailability &&
902
+ totalQuantity > 0 &&
903
+ addOnCatalogLoaded &&
904
+ ((selectedAvailability.returnOptions?.length ?? 0) === 0 || selectedReturnOption),
905
+ );
900
906
  const handleRefundDispositionChange = useCallback((
901
907
  operationId: string,
902
908
  disposition: AdminReleaseRefundDisposition | null,
@@ -918,8 +924,8 @@ export function AdminChangeBookingFlow({
918
924
  } = useAdminChangeQuotePreview({
919
925
  isCustomerSelfServeChange,
920
926
  isProviderDashboardChange,
921
- hasChangeSelection,
922
927
  hasEffectiveChangeSelection,
928
+ initialSelectionHydrated: initialSelectionHydratedForQuote,
923
929
  selectedAvailability,
924
930
  selectedReturnOption,
925
931
  initialValues,
@@ -363,7 +363,7 @@ export function AdminChangeReceiptComparison({
363
363
  </section>
364
364
 
365
365
  <section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
366
- <div className="mb-3 flex items-center justify-between gap-3">
366
+ <div className="mb-3">
367
367
  <div>
368
368
  <h3 className="text-sm font-semibold text-stone-900">
369
369
  {isProductFamilyConversion ? 'New purchase' : 'Current change'}
@@ -374,29 +374,20 @@ export function AdminChangeReceiptComparison({
374
374
  </p>
375
375
  ) : null}
376
376
  </div>
377
- <div className="flex shrink-0 items-center gap-2">
378
- {onPreserveExistingReceiptChange ? (
379
- <button
380
- type="button"
381
- aria-pressed={preserveExistingReceipt}
382
- className={`rounded-md border px-2.5 py-1.5 text-xs font-semibold transition ${
383
- preserveExistingReceipt
384
- ? 'border-sky-700 bg-sky-700 text-white'
385
- : 'border-stone-300 bg-white text-stone-700 hover:border-sky-500 hover:text-sky-800'
386
- }`}
387
- onClick={() => onPreserveExistingReceiptChange(!preserveExistingReceipt)}
388
- >
389
- Keep receipt unchanged
390
- </button>
391
- ) : null}
392
- <span className="text-xs font-medium text-stone-500">
393
- {isProductFamilyConversion
394
- ? formatCurrencyAmount(updatedTotal ?? 0, currency, displayLocale)
395
- : bookingTotalChange == null
396
- ? '—'
397
- : formatCurrencyAmount(bookingTotalChange, currency, displayLocale)}
398
- </span>
399
- </div>
377
+ {onPreserveExistingReceiptChange ? (
378
+ <button
379
+ type="button"
380
+ aria-pressed={preserveExistingReceipt}
381
+ className={`mt-1.5 inline-flex text-sm font-semibold underline decoration-1 underline-offset-4 transition ${
382
+ preserveExistingReceipt
383
+ ? 'text-emerald-800 decoration-emerald-700 hover:text-emerald-950'
384
+ : 'text-emerald-700 decoration-emerald-600 hover:text-emerald-900'
385
+ }`}
386
+ onClick={() => onPreserveExistingReceiptChange(!preserveExistingReceipt)}
387
+ >
388
+ Keep receipt unchanged
389
+ </button>
390
+ ) : null}
400
391
  </div>
401
392
  {preserveExistingReceipt ? (
402
393
  <div className="mb-3 rounded-md border border-sky-200 bg-white px-3 py-2">
@@ -24,6 +24,7 @@ interface AdminChangeQuoteRequestKeyInput {
24
24
  interface AdminChangeQuoteRequestGateInput {
25
25
  isCustomerSelfServeChange: boolean;
26
26
  hasEffectiveChangeSelection: boolean;
27
+ initialSelectionHydrated?: boolean;
27
28
  hasSelectedAvailability: boolean;
28
29
  bookingReference?: string | null;
29
30
  lastName: string;
@@ -45,6 +46,7 @@ export function canRequestAdminChangeQuote(input: AdminChangeQuoteRequestGateInp
45
46
  return Boolean(
46
47
  input.isCustomerSelfServeChange &&
47
48
  input.hasEffectiveChangeSelection &&
49
+ input.initialSelectionHydrated !== false &&
48
50
  input.hasSelectedAvailability &&
49
51
  input.bookingReference?.trim() &&
50
52
  input.lastName.trim() &&
@@ -93,7 +93,7 @@ export function incompatibleAddOnChangeError(
93
93
 
94
94
  function errorCodeFromMessage(message: string): string | null {
95
95
  const match = message.match(
96
- /\b(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|private_shuttle|quote|structured_adjustment)_[a-z0-9_]+/i,
96
+ /\b(?:(?:missing_(?:add|remove)_operation):[a-z0-9_]+|(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|private_shuttle|quote|structured_adjustment)_[a-z0-9_]+)/i,
97
97
  );
98
98
  return match?.[0]?.toLowerCase() ?? null;
99
99
  }
@@ -112,6 +112,10 @@ export function friendlyChangeBookingError(message: string | null | undefined):
112
112
  const exact = CHANGE_BOOKING_ERROR_MESSAGES[code];
113
113
  if (exact) return exact;
114
114
 
115
+ if (code.startsWith('missing_add_operation:return') || code.startsWith('missing_remove_operation:return')) {
116
+ return 'The return portion of this booking could not be priced. Re-select the return time and try again.';
117
+ }
118
+
115
119
  if (code.startsWith('admin_adjustment_refund_disposition_required')) {
116
120
  return 'Choose how to handle the removed adjustment value before continuing.';
117
121
  }
@@ -64,8 +64,8 @@ interface ChangeSelectionDetailsForPreview {
64
64
  export interface UseAdminChangeQuotePreviewParams {
65
65
  isCustomerSelfServeChange: boolean;
66
66
  isProviderDashboardChange: boolean;
67
- hasChangeSelection: boolean;
68
67
  hasEffectiveChangeSelection: boolean;
68
+ initialSelectionHydrated: boolean;
69
69
  selectedAvailability: Availability | null;
70
70
  selectedReturnOption: ReturnOption | null;
71
71
  initialValues: ChangeBookingFlowProps['initialValues'];
@@ -129,8 +129,8 @@ export interface UseAdminChangeQuotePreviewResult {
129
129
  export function useAdminChangeQuotePreview({
130
130
  isCustomerSelfServeChange,
131
131
  isProviderDashboardChange,
132
- hasChangeSelection,
133
132
  hasEffectiveChangeSelection,
133
+ initialSelectionHydrated,
134
134
  selectedAvailability,
135
135
  selectedReturnOption,
136
136
  initialValues,
@@ -194,37 +194,10 @@ export function useAdminChangeQuotePreview({
194
194
  (latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
195
195
  const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
196
196
  const hasPublishedChangeQuote = latestChangeQuote !== null;
197
- /**
198
- * Existing admin adjustments must be discoverable before the operator makes a change.
199
- * Initial selection hydration can briefly leave the live availability/return unset, so use
200
- * the persisted booking identities for this read-only discovery quote instead of waiting for
201
- * the editable target controls to become complete.
202
- */
203
- const quoteAvailability = useMemo<Availability | null>(() => {
204
- if (selectedAvailability) return selectedAvailability;
205
- if (!needsAdjustmentDiscovery || !initialValues?.dateTime?.trim()) return null;
206
- const optionId = initialValues.productOptionId?.trim() || initialValues.productId?.trim();
207
- if (!optionId) return null;
208
- return {
209
- dateTime: initialValues.dateTime.trim(),
210
- availabilityId: initialValues.availabilityId?.trim() || undefined,
211
- productOptionId: optionId,
212
- vacancies: Math.max(totalQuantity, 1),
213
- currency,
214
- };
215
- }, [
216
- selectedAvailability,
217
- needsAdjustmentDiscovery,
218
- initialValues?.dateTime,
219
- initialValues?.availabilityId,
220
- initialValues?.productOptionId,
221
- initialValues?.productId,
222
- totalQuantity,
223
- currency,
224
- ]);
225
- const quoteReturnAvailabilityId =
226
- selectedReturnOption?.returnAvailabilityId ??
227
- (needsAdjustmentDiscovery ? initialValues?.returnAvailabilityId?.trim() || null : null);
197
+ // Existing-adjustment discovery is still a real quote. Never send it with the temporary
198
+ // empty ticket/return state produced while the saved booking is being rehydrated.
199
+ const quoteAvailability = selectedAvailability;
200
+ const quoteReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId ?? null;
228
201
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
229
202
  bookingReference: initialValues?.bookingReference,
230
203
  lastName,
@@ -264,15 +237,14 @@ export function useAdminChangeQuotePreview({
264
237
  ]);
265
238
  const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
266
239
  const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
267
- const quoteMissingRequiredReturnSelection =
268
- missingRequiredReturnSelection && !needsAdjustmentDiscovery;
269
240
  const canRequestChangeQuote = canRequestAdminChangeQuote({
270
241
  isCustomerSelfServeChange,
271
- hasEffectiveChangeSelection,
242
+ hasEffectiveChangeSelection: hasEffectiveChangeSelection || needsAdjustmentDiscovery,
243
+ initialSelectionHydrated,
272
244
  hasSelectedAvailability: quoteAvailability != null,
273
245
  bookingReference: initialValues?.bookingReference,
274
246
  lastName,
275
- missingRequiredReturnSelection: quoteMissingRequiredReturnSelection,
247
+ missingRequiredReturnSelection,
276
248
  });
277
249
  const changeFlowSubmitDisabled =
278
250
  missingRequiredReturnSelection ||
@@ -45,6 +45,16 @@ export interface UseChangeBookingInitialHydrationOptions {
45
45
  setError: Dispatch<SetStateAction<string>>;
46
46
  }
47
47
 
48
+ export function initialPromoCodeToApply(
49
+ initialPromoCode: string | null | undefined,
50
+ lockedPromoCode: string | null,
51
+ ): string | null {
52
+ if (!lockedPromoCode) return null;
53
+ const normalizedInitial = initialPromoCode?.trim().toUpperCase() ?? '';
54
+ const normalizedLocked = lockedPromoCode.trim().toUpperCase();
55
+ return normalizedInitial && normalizedInitial === normalizedLocked ? normalizedInitial : null;
56
+ }
57
+
48
58
  export function useChangeBookingInitialHydration({
49
59
  initialValues,
50
60
  lockedPromoCode,
@@ -84,7 +94,7 @@ export function useChangeBookingInitialHydration({
84
94
  const trimmedEmail = initialValues.customer?.email?.trim();
85
95
  const trimmedFirstName = initialValues.customer?.firstName?.trim();
86
96
  const trimmedLastName = initialValues.customer?.lastName?.trim();
87
- const trimmedPromo = initialValues.promoCode?.trim().toUpperCase();
97
+ const initialPromoToApply = initialPromoCodeToApply(initialValues.promoCode, lockedPromoCode);
88
98
  if (trimmedEmail) setEmail(trimmedEmail);
89
99
  if (trimmedFirstName) setFirstName(trimmedFirstName);
90
100
  if (trimmedLastName) setLastName(trimmedLastName);
@@ -92,13 +102,16 @@ export function useChangeBookingInitialHydration({
92
102
  setPickupLocationId(initialValues.pickupLocationId.trim());
93
103
  setPickupLocationSkipped(false);
94
104
  }
95
- if (trimmedPromo) {
96
- setPromoCodeInput(trimmedPromo);
97
- setAppliedPromoCode(trimmedPromo);
105
+ // Provider amendments show the historical code separately and require the operator to
106
+ // explicitly apply it again. Only public/self-serve flows pass a locked promo here.
107
+ if (initialPromoToApply) {
108
+ setPromoCodeInput(initialPromoToApply);
109
+ setAppliedPromoCode(initialPromoToApply);
98
110
  }
99
111
  hasAppliedInitialValuesRef.current = true;
100
112
  }, [
101
113
  initialValues,
114
+ lockedPromoCode,
102
115
  setAppliedPromoCode,
103
116
  setEmail,
104
117
  setFirstName,
@@ -35,6 +35,7 @@ import {
35
35
  canReuseCompletedAdminChangeQuote,
36
36
  isAdminRefundDecisionDiscoveryQuote,
37
37
  } from '../src/components/booking/admin-change-quote-request-key';
38
+ import { initialPromoCodeToApply } from '../src/components/booking/useChangeBookingInitialHydration';
38
39
  import {
39
40
  authoritativeItineraryDisplayFromChangeQuote,
40
41
  sliceAndMergeChangeQuoteForUi,
@@ -762,6 +763,21 @@ test('admin quote gate allows adjustment-only changes', () => {
762
763
  lastName: 'Tauro',
763
764
  missingRequiredReturnSelection: false,
764
765
  }), false);
766
+
767
+ assert.equal(canRequestAdminChangeQuote({
768
+ isCustomerSelfServeChange: true,
769
+ hasEffectiveChangeSelection: true,
770
+ initialSelectionHydrated: false,
771
+ hasSelectedAvailability: true,
772
+ bookingReference: 'DIFG66OF',
773
+ lastName: 'Tauro',
774
+ missingRequiredReturnSelection: false,
775
+ }), false);
776
+ });
777
+
778
+ test('provider reopen does not automatically reapply the historical promo', () => {
779
+ assert.equal(initialPromoCodeToApply(' LOCKEDIN2026 ', null), null);
780
+ assert.equal(initialPromoCodeToApply(' LOCKEDIN2026 ', 'LOCKEDIN2026'), 'LOCKEDIN2026');
765
781
  });
766
782
 
767
783
  test('admin preview and checkout share the Pricing V2 request shape', () => {
@@ -2272,6 +2288,10 @@ test('translates booking-change reason codes into operator-facing messages', ()
2272
2288
  friendlyChangeBookingError('private_shuttle_itinerary_endpoints_required'),
2273
2289
  'The private-tour itinerary is incomplete. Reopen the change and select the pickup time and location again.',
2274
2290
  );
2291
+ assert.equal(
2292
+ friendlyChangeBookingError('missing_add_operation:return'),
2293
+ 'The return portion of this booking could not be priced. Re-select the return time and try again.',
2294
+ );
2275
2295
  assert.equal(
2276
2296
  friendlyChangeBookingError('Remove the unavailable add-on before recalculating this amendment.'),
2277
2297
  'Remove the unavailable add-on before recalculating this amendment.',