@ticketboothapp/booking 1.2.167 → 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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +13 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +163 -145
  7. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  8. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  9. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  10. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  11. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  12. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  13. package/src/components/booking/PriceSummary.tsx +4 -0
  14. package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
  15. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  16. package/src/components/booking/admin-adjustment-components.ts +44 -0
  17. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  18. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  19. package/src/components/booking/admin-change-provider-payload.ts +3 -0
  20. package/src/components/booking/admin-change-quote-request-key.ts +27 -0
  21. package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
  22. package/src/components/booking/admin-refund-decision-context.ts +37 -0
  23. package/src/components/booking/admin-refund-disposition.ts +71 -2
  24. package/src/components/booking/booking-flow-types.ts +4 -0
  25. package/src/components/booking/booking-flow.css +5 -0
  26. package/src/components/booking/change-booking-error-message.ts +137 -0
  27. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  28. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  29. package/src/components/booking/change-booking-quote-state.ts +44 -1
  30. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  31. package/src/components/booking/private-shuttle-availability.ts +14 -0
  32. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  33. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  34. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  35. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  36. package/src/components/booking/provider-dashboard-change-booking.ts +16 -1
  37. package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
  38. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  39. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  40. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
  41. package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
  42. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  43. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  44. package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
  45. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  46. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  47. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  48. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  49. package/src/lib/booking/change-booking-server-preview.ts +26 -9
  50. package/src/lib/booking/change-flow-pricing.ts +12 -0
  51. package/src/lib/booking/i18n/messages/en.json +1 -0
  52. package/src/lib/booking/i18n/messages/fr.json +1 -0
  53. package/src/lib/booking-api.ts +64 -0
  54. package/test/change-booking-helpers.test.ts +853 -3
  55. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -11,15 +11,12 @@ import type { Currency } from './CurrencySwitcher';
11
11
  import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
12
12
  import { collapseAdminPromoLines } from './admin-change-receipt-lines';
13
13
  import {
14
- adminRemovalOperationLabel,
15
14
  noRefundRemovedValue,
16
- normalizeReturnPriceTreatment,
17
15
  refundDecisionsComplete,
18
- releaseRefundDispositionOptions,
19
- returnPriceTreatmentOptions,
20
16
  type AdminReleaseRefundDisposition,
21
- type AdminReturnPriceTreatment,
22
17
  } from './admin-refund-disposition';
18
+ import { friendlyChangeBookingError } from './change-booking-error-message';
19
+ import { isProductFamilyReceiptConversion } from './change-booking-flow-helpers';
23
20
 
24
21
  type TranslationFn = (key: string, params?: Record<string, string>) => string;
25
22
  type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
@@ -28,10 +25,17 @@ export interface AdminChangeReceiptComparisonProps {
28
25
  originalReceipt: OriginalReceipt;
29
26
  /** Amendment-only rows returned by the Pricing V2 server quote. */
30
27
  amendmentLines: PriceSummaryLine[] | null;
28
+ /** Full receipt after the amendment, used for clear family-conversion comparisons. */
29
+ resultingReceiptLines?: PriceSummaryLine[] | null;
31
30
  /** Signed server balance delta. Never calculated by this component. */
32
31
  amountDue: number | null;
33
32
  /** Full resulting booking total returned by the same server quote. */
34
33
  updatedTotal: number | null;
34
+ /** Settled cash after completed refunds, calculated by the backend. */
35
+ netPaid?: number | null;
36
+ currentPaymentCredit?: number;
37
+ projectedPaymentCredit?: number;
38
+ refundCandidate?: number;
35
39
  selectionChanged: boolean;
36
40
  quoteError?: string | null;
37
41
  amountDueLabel: string;
@@ -52,6 +56,8 @@ export interface AdminChangeReceiptComparisonProps {
52
56
  disposition: AdminReleaseRefundDisposition | null,
53
57
  ) => void;
54
58
  refundQuoteLoading?: boolean;
59
+ sourceProductName?: string | null;
60
+ targetProductName?: string | null;
55
61
  }
56
62
 
57
63
  function normalizeLocale(locale: string): Locale {
@@ -82,8 +88,13 @@ function amendmentLineOrder(line: PriceSummaryLine): number {
82
88
  export function AdminChangeReceiptComparison({
83
89
  originalReceipt,
84
90
  amendmentLines,
91
+ resultingReceiptLines = null,
85
92
  amountDue,
86
93
  updatedTotal,
94
+ netPaid,
95
+ currentPaymentCredit = 0,
96
+ projectedPaymentCredit = 0,
97
+ refundCandidate = 0,
87
98
  selectionChanged,
88
99
  quoteError,
89
100
  amountDueLabel,
@@ -95,30 +106,47 @@ export function AdminChangeReceiptComparison({
95
106
  promoEditor,
96
107
  promoCode,
97
108
  refundDecisionOperations = [],
98
- returnPriceTreatmentOperations = [],
99
- allowedRefundDispositionsByOperationId = {},
100
109
  removedValueByOperationId = {},
101
110
  refundDispositionsByOperationId = {},
102
- onRefundDispositionChange,
103
111
  refundQuoteLoading = false,
104
112
  }: AdminChangeReceiptComparisonProps) {
113
+ const readableQuoteError = friendlyChangeBookingError(quoteError);
105
114
  const displayLocale = normalizeLocale(locale);
106
115
  const existingLines = originalLines(originalReceipt);
107
116
  const originalCurrency = originalReceipt.currency ?? currency;
108
117
  const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
118
+ const settlementReady = quoteReady && netPaid != null;
119
+ const currentBalanceOwing = settlementReady
120
+ ? Math.max(
121
+ 0,
122
+ Math.round(
123
+ (originalReceipt.total - netPaid - currentPaymentCredit) * 100,
124
+ ) / 100,
125
+ )
126
+ : null;
127
+ const projectedBalanceOwing = settlementReady
128
+ ? Math.max(
129
+ 0,
130
+ Math.round(
131
+ (updatedTotal - netPaid - projectedPaymentCredit) * 100,
132
+ ) / 100,
133
+ )
134
+ : null;
135
+ const balanceReduction = settlementReady
136
+ ? Math.max(
137
+ 0,
138
+ Math.round(((currentBalanceOwing ?? 0) - (projectedBalanceOwing ?? 0)) * 100) / 100,
139
+ )
140
+ : 0;
109
141
  const hasRefundDecisions = refundDecisionOperations.length > 0;
110
- const hasReturnPriceTreatments = returnPriceTreatmentOperations.length > 0;
111
142
  const refundSelectionsComplete = refundDecisionsComplete(
112
143
  refundDecisionOperations,
113
144
  refundDispositionsByOperationId,
114
145
  );
115
146
  const settlementPending = hasRefundDecisions && (!refundSelectionsComplete || refundQuoteLoading);
116
- const returnRepricePending = hasReturnPriceTreatments &&
117
- returnPriceTreatmentOperations.some(
118
- (operation) => refundDispositionsByOperationId[operation.operationId] != null,
119
- ) &&
120
- refundQuoteLoading;
121
147
  const displayedAmountDue = settlementPending ? null : amountDue;
148
+ const bookingTotalChange =
149
+ updatedTotal == null ? null : Math.round((updatedTotal - originalReceipt.total) * 100) / 100;
122
150
  const removedWithoutRefund = noRefundRemovedValue(
123
151
  refundDecisionOperations,
124
152
  refundDispositionsByOperationId,
@@ -151,13 +179,42 @@ export function AdminChangeReceiptComparison({
151
179
  ...(Math.abs(amendmentTax) >= 0.005
152
180
  ? [{
153
181
  kind: 'line' as const,
154
- label: amendmentTax < 0 ? 'Refunded tax' : 'Additional tax',
182
+ label: amendmentTax < 0 ? 'Tax reduction' : 'Additional tax',
155
183
  amount: amendmentTax,
156
184
  type: 'TAX',
157
185
  }]
158
186
  : []),
159
187
  ]
160
188
  : [];
189
+ const familyConversionReceiptLines = collapseAdminPromoLines(
190
+ resultingReceiptLines ?? [],
191
+ promoCode,
192
+ );
193
+ const isProductFamilyConversion = isProductFamilyReceiptConversion(
194
+ existingLines,
195
+ familyConversionReceiptLines,
196
+ );
197
+ const summaryDisplayLines = isProductFamilyConversion
198
+ ? familyConversionReceiptLines
199
+ : amendmentDisplayLines;
200
+ const summarySubtotal = isProductFamilyConversion
201
+ ? familyConversionReceiptLines.reduce((sum, line) => {
202
+ if (line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX') return sum;
203
+ return sum + (line.kind === 'ticket' ? line.itemTotal : line.amount);
204
+ }, 0)
205
+ : amendmentSubtotal;
206
+ const summaryTotal = isProductFamilyConversion
207
+ ? updatedTotal
208
+ : bookingTotalChange ?? amountDue;
209
+ const translatedChangeTotalLabel = t('booking.changeToBookingTotal');
210
+ const summaryTotalLabel = isProductFamilyConversion
211
+ ? 'New booking total'
212
+ : translatedChangeTotalLabel && translatedChangeTotalLabel !== 'booking.changeToBookingTotal'
213
+ ? translatedChangeTotalLabel
214
+ : 'Change to booking total';
215
+ const displayedSettlementAmount = refundCandidate > 0.005
216
+ ? refundCandidate
217
+ : displayedAmountDue;
161
218
  const amountDueClass =
162
219
  displayedAmountDue != null && displayedAmountDue < -0.005
163
220
  ? 'text-emerald-700'
@@ -167,107 +224,6 @@ export function AdminChangeReceiptComparison({
167
224
 
168
225
  return (
169
226
  <div className="min-w-0 space-y-3 overflow-visible">
170
- {hasReturnPriceTreatments ? (
171
- <section className="rounded-lg border border-sky-300 bg-sky-50 p-3" aria-label="Return price treatment">
172
- <div className="mb-3">
173
- <h3 className="text-sm font-semibold text-stone-900">Return price</h3>
174
- <p className="mt-1 text-xs text-stone-600">
175
- By default the original paid return price stays on the booking. You can optionally reduce it and choose refund treatment.
176
- </p>
177
- </div>
178
- <div className="space-y-3">
179
- {returnPriceTreatmentOperations.map((operation) => {
180
- const options = returnPriceTreatmentOptions(
181
- allowedRefundDispositionsByOperationId[operation.operationId],
182
- );
183
- const selectedValue: AdminReturnPriceTreatment = normalizeReturnPriceTreatment(
184
- refundDispositionsByOperationId[operation.operationId],
185
- );
186
- const selected = options.find((option) => option.value === selectedValue);
187
- return (
188
- <div key={operation.operationId} className="rounded-md border border-sky-200 bg-white p-3">
189
- <div className="mb-2 text-sm font-medium capitalize text-stone-900">
190
- {adminRemovalOperationLabel(operation)}
191
- </div>
192
- <select
193
- className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
194
- value={selectedValue}
195
- onChange={(event) => {
196
- const value = event.target.value as AdminReturnPriceTreatment;
197
- onRefundDispositionChange?.(
198
- operation.operationId,
199
- value === 'KEEP_FLOOR' ? null : value,
200
- );
201
- }}
202
- aria-label={`Return price treatment for ${adminRemovalOperationLabel(operation)}`}
203
- >
204
- {options.map((option) => (
205
- <option key={option.value} value={option.value}>{option.label}</option>
206
- ))}
207
- </select>
208
- {selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
209
- </div>
210
- );
211
- })}
212
- </div>
213
- {returnRepricePending ? (
214
- <p className="mt-3 text-xs font-medium text-sky-800">Recalculating settlement…</p>
215
- ) : null}
216
- </section>
217
- ) : null}
218
-
219
- {hasRefundDecisions ? (
220
- <section className="rounded-lg border border-amber-300 bg-amber-50 p-3" aria-label="Refund treatment">
221
- <div className="mb-3">
222
- <h3 className="text-sm font-semibold text-stone-900">Choose refund treatment</h3>
223
- <p className="mt-1 text-xs text-stone-600">
224
- Removed paid value is never refunded automatically. Choose an explicit treatment, then the server will recalculate the settlement.
225
- </p>
226
- </div>
227
- <div className="space-y-3">
228
- {refundDecisionOperations.map((operation) => {
229
- const options = releaseRefundDispositionOptions(
230
- allowedRefundDispositionsByOperationId[operation.operationId],
231
- );
232
- const selectedValue = refundDispositionsByOperationId[operation.operationId];
233
- const selected = options.find((option) => option.value === selectedValue);
234
- const removedValue = removedValueByOperationId[operation.operationId];
235
- return (
236
- <div key={operation.operationId} className="rounded-md border border-amber-200 bg-white p-3">
237
- <div className="mb-2 flex flex-wrap items-start justify-between gap-2 text-sm font-medium text-stone-900">
238
- <span className="capitalize">{adminRemovalOperationLabel(operation)}</span>
239
- {Number.isFinite(removedValue) ? (
240
- <span>{formatCurrencyAmount(removedValue, currency, displayLocale)}</span>
241
- ) : null}
242
- </div>
243
- <select
244
- className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
245
- value={selectedValue ?? ''}
246
- onChange={(event) => {
247
- const value = event.target.value;
248
- onRefundDispositionChange?.(
249
- operation.operationId,
250
- value ? value as AdminReleaseRefundDisposition : null,
251
- );
252
- }}
253
- aria-label={`Refund treatment for ${adminRemovalOperationLabel(operation)}`}
254
- >
255
- <option value="">Choose refund treatment…</option>
256
- {options.map((option) => (
257
- <option key={option.value} value={option.value}>{option.label}</option>
258
- ))}
259
- </select>
260
- {selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
261
- </div>
262
- );
263
- })}
264
- </div>
265
- {refundQuoteLoading && refundSelectionsComplete ? (
266
- <p className="mt-3 text-xs font-medium text-amber-800">Recalculating settlement…</p>
267
- ) : null}
268
- </section>
269
- ) : null}
270
-
271
227
  <div className="grid gap-4 md:grid-cols-2">
272
228
  <section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
273
229
  <div className="mb-3 flex items-center justify-between gap-3">
@@ -292,11 +248,21 @@ export function AdminChangeReceiptComparison({
292
248
  <section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
293
249
  <div className="mb-3 flex items-center justify-between gap-3">
294
250
  <div>
295
- <h3 className="text-sm font-semibold text-stone-900">This change</h3>
296
- <p className="mt-0.5 text-xs text-stone-500">Server-priced amendment</p>
251
+ <h3 className="text-sm font-semibold text-stone-900">
252
+ {isProductFamilyConversion ? 'New purchase' : 'Current change'}
253
+ </h3>
254
+ {isProductFamilyConversion ? (
255
+ <p className="mt-0.5 text-xs text-stone-500">
256
+ Server-priced booking after conversion
257
+ </p>
258
+ ) : null}
297
259
  </div>
298
260
  <span className="shrink-0 text-xs font-medium text-stone-500">
299
- {displayedAmountDue == null ? '—' : formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
261
+ {isProductFamilyConversion
262
+ ? formatCurrencyAmount(updatedTotal ?? 0, currency, displayLocale)
263
+ : bookingTotalChange == null
264
+ ? '—'
265
+ : formatCurrencyAmount(bookingTotalChange, currency, displayLocale)}
300
266
  </span>
301
267
  </div>
302
268
  {promoEditor}
@@ -305,37 +271,38 @@ export function AdminChangeReceiptComparison({
305
271
  <p className="text-sm font-medium text-stone-700">No changes selected</p>
306
272
  <p className="mt-1 text-xs text-stone-500">Change tickets or another booking option to request a price.</p>
307
273
  </div>
308
- ) : quoteError ? (
274
+ ) : readableQuoteError ? (
309
275
  <div className="rounded-md border border-red-200 bg-red-50 px-3 py-4 text-center" role="alert">
310
- <p className="text-sm font-medium text-red-800">{quoteError}</p>
276
+ <p className="text-sm font-medium text-red-800">{readableQuoteError}</p>
311
277
  </div>
312
278
  ) : !quoteReady ? (
313
279
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
314
- <p className="text-sm font-medium text-stone-700">Waiting for server price…</p>
280
+ <p className="text-sm font-medium text-stone-700">Loading price change…</p>
315
281
  </div>
316
282
  ) : settlementPending ? (
317
- <div className="rounded-md border border-dashed border-amber-300 bg-white/70 px-3 py-4 text-center">
318
- <p className="text-sm font-medium text-amber-900">Refund treatment required</p>
319
- <p className="mt-1 text-xs text-stone-600">Choose how to handle the removed paid value above.</p>
283
+ <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
284
+ <p className="text-sm font-medium text-stone-700">Loading price change…</p>
320
285
  </div>
321
- ) : amendmentDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
286
+ ) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
322
287
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
323
288
  <p className="text-sm font-medium text-stone-800">No refund will be created</p>
324
289
  <p className="mt-1 text-xs text-stone-600">
325
290
  {formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
326
291
  </p>
327
292
  </div>
328
- ) : amendmentDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
293
+ ) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
329
294
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
330
295
  <p className="text-sm font-medium text-stone-700">No pricing changes</p>
331
296
  </div>
332
297
  ) : (
333
298
  <PriceSummary
334
- lines={amendmentDisplayLines}
335
- total={amountDue}
299
+ lines={summaryDisplayLines}
300
+ total={summaryTotal ?? amountDue}
301
+ totalLabel={summaryTotalLabel}
302
+ showPositiveTotalSign={!isProductFamilyConversion}
336
303
  currency={currency}
337
304
  locale={displayLocale}
338
- subtotal={amendmentSubtotal}
305
+ subtotal={summarySubtotal}
339
306
  size="sm"
340
307
  t={t}
341
308
  />
@@ -343,31 +310,82 @@ export function AdminChangeReceiptComparison({
343
310
  </section>
344
311
  </div>
345
312
 
346
- {quoteReady ? (
347
- <section className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-300 bg-white px-3 py-3 shadow-sm">
348
- <div>
349
- <h3 className="text-sm font-semibold text-stone-900">Updated booking total</h3>
350
- <p className="mt-0.5 text-xs text-stone-500">Authoritative total returned by the server quote</p>
351
- </div>
352
- <span className="text-lg font-semibold tabular-nums text-stone-900">
353
- {formatCurrencyAmount(updatedTotal, currency, displayLocale)}
354
- </span>
355
- </section>
356
- ) : null}
357
-
358
313
  {adjustments ? (
359
314
  <section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
360
- <h3 className="mb-2 text-sm font-semibold text-stone-900">Admin adjustments</h3>
315
+ <h3 className="mb-2 text-sm font-semibold text-stone-900">Add custom line items</h3>
361
316
  {adjustments}
362
317
  </section>
363
318
  ) : null}
364
319
 
320
+ {quoteReady ? (
321
+ <section className="rounded-lg border border-stone-300 bg-white px-3 py-3 shadow-sm">
322
+ <h3 className="text-sm font-semibold text-stone-900">After this change</h3>
323
+ {settlementReady ? (
324
+ <div className="mt-3 space-y-2 border-t border-stone-200 pt-3 text-xs">
325
+ <div className="flex items-center justify-between gap-3 text-stone-600">
326
+ <span>Updated booking total</span>
327
+ <span className="font-medium tabular-nums text-stone-900">
328
+ {formatCurrencyAmount(updatedTotal, currency, displayLocale)}
329
+ </span>
330
+ </div>
331
+ <div className="flex items-center justify-between gap-3 text-stone-600">
332
+ <span>Customer already paid</span>
333
+ <span className="font-medium tabular-nums text-stone-900">
334
+ {formatCurrencyAmount(netPaid, currency, displayLocale)}
335
+ </span>
336
+ </div>
337
+ {projectedPaymentCredit > 0.005 ? (
338
+ <div className="flex items-center justify-between gap-3 text-stone-600">
339
+ <span>Payment credit applied</span>
340
+ <span className="font-medium tabular-nums text-stone-900">
341
+ {formatCurrencyAmount(projectedPaymentCredit, currency, displayLocale)}
342
+ </span>
343
+ </div>
344
+ ) : null}
345
+ <div className="flex items-center justify-between gap-3 text-stone-600">
346
+ <span>Balance owing before change</span>
347
+ <span className="font-medium tabular-nums text-stone-900">
348
+ {formatCurrencyAmount(currentBalanceOwing ?? 0, currency, displayLocale)}
349
+ </span>
350
+ </div>
351
+ {balanceReduction > 0.005 ? (
352
+ <div className="flex items-center justify-between gap-3 font-semibold text-emerald-700">
353
+ <span>
354
+ {isProductFamilyConversion
355
+ ? 'Previous unpaid balance removed'
356
+ : 'Balance reduction'}
357
+ </span>
358
+ <span className="tabular-nums">
359
+ {formatCurrencyAmount(balanceReduction, currency, displayLocale)}
360
+ </span>
361
+ </div>
362
+ ) : null}
363
+ {refundCandidate > 0.005 ? (
364
+ <div className="flex items-center justify-between gap-3 font-semibold text-emerald-700">
365
+ <span>Amount to refund customer</span>
366
+ <span className="tabular-nums">
367
+ {formatCurrencyAmount(refundCandidate, currency, displayLocale)}
368
+ </span>
369
+ </div>
370
+ ) : amountDue < -0.005 ? (
371
+ <div className="flex items-center justify-between gap-3 font-semibold text-stone-600">
372
+ <span>Amount to refund customer</span>
373
+ <span className="tabular-nums">
374
+ {formatCurrencyAmount(0, currency, displayLocale)}
375
+ </span>
376
+ </div>
377
+ ) : null}
378
+ </div>
379
+ ) : null}
380
+ </section>
381
+ ) : null}
382
+
365
383
  <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
366
384
  <span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
367
385
  <span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
368
- {!selectionChanged || displayedAmountDue == null
386
+ {!selectionChanged || displayedSettlementAmount == null
369
387
  ? '—'
370
- : formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
388
+ : formatCurrencyAmount(displayedSettlementAmount, currency, displayLocale)}
371
389
  </span>
372
390
  </div>
373
391
  </div>
@@ -149,7 +149,7 @@ export function ChangeBookingFlow({
149
149
  useSeedPromoCodeInput(autoAppliedPromoCode, setPromoCodeInput);
150
150
  const [loading, setLoading] = useState(false);
151
151
  const [error, setError] = useState('');
152
- const [termsAccepted, setTermsAccepted] = useState(false);
152
+ const [termsAccepted, setTermsAccepted] = useState(true);
153
153
  const [, setTermsAcceptedAt] = useState<string | null>(null);
154
154
  const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
155
155
  const changeFlowOriginalDate = useMemo(
@@ -304,6 +304,8 @@ export function ChangeBookingFlow({
304
304
  timesForSelectedDate,
305
305
  companyTimezone,
306
306
  changeFlowResolvedInitialProductOptionId,
307
+ mostPopularProductOptionId:
308
+ activeOptions.find((option) => option.mostPopular)?.optionId ?? null,
307
309
  precomputedPricesByOption,
308
310
  currency,
309
311
  originalReceiptSubtotal: originalReceipt?.subtotal,
@@ -325,6 +327,7 @@ export function ChangeBookingFlow({
325
327
  });
326
328
 
327
329
  const {
330
+ initialAddOnQtyByKey,
328
331
  initialAddOnMinQtyByKey,
329
332
  initialAddOnMinTotalByAddOnId,
330
333
  updateAddOnSelections,
@@ -332,7 +335,7 @@ export function ChangeBookingFlow({
332
335
  initialValues,
333
336
  originalReceiptLineItems: originalReceipt?.lineItems,
334
337
  addOns,
335
- isCustomerSelfServeChange,
338
+ lockExistingAddOnQuantities: isCustomerSelfServeChange,
336
339
  setAddOnSelections,
337
340
  });
338
341
 
@@ -571,7 +574,7 @@ export function ChangeBookingFlow({
571
574
  pickupLocationId,
572
575
  quantities,
573
576
  addOnSelections,
574
- initialAddOnMinQtyByKey,
577
+ initialAddOnQtyByKey,
575
578
  changeFlowResolvedInitialProductOptionId,
576
579
  });
577
580
  const hasChangeSelection = changeSelectionDetails.hasChangesFromInitial;
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import { friendlyChangeBookingError } from './change-booking-error-message';
2
3
 
3
4
  export interface ChangeBookingQuoteStatusPlaceholderParams {
4
5
  suppressSelfServeCurrencyUi: boolean;
@@ -37,14 +38,18 @@ export function buildChangeBookingQuoteStatusPlaceholder({
37
38
  if (changeQuoteFetchError) {
38
39
  return (
39
40
  <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
40
- {changeQuoteFetchError}
41
+ {friendlyChangeBookingError(changeQuoteFetchError)}
41
42
  </div>
42
43
  );
43
44
  }
44
45
  if (quoteCanProceed === false) {
45
46
  return (
46
47
  <div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
47
- <div>{quoteBlockedReason ?? 'This booking change is not available.'}</div>
48
+ <div>
49
+ {friendlyChangeBookingError(
50
+ quoteBlockedReason ?? 'This booking change is not available.',
51
+ )}
52
+ </div>
48
53
  {blockedDetails}
49
54
  </div>
50
55
  );
@@ -42,6 +42,8 @@ export interface ChangeBookingSelectionControlsPanelProps {
42
42
  applyReceiptPaidFloors: boolean;
43
43
  ticketUnitFloorByCategory?: Map<string, number>;
44
44
  addOns: AddOn[];
45
+ knownAddOns?: AddOn[];
46
+ addOnCatalogLoaded?: boolean;
45
47
  addOnSelections: AddOnSelection[];
46
48
  onAddOnSelectionsChange: AddOnSelectionsChangeHandler;
47
49
  minimumTotalByAddOnId?: Map<string, number>;
@@ -80,6 +82,8 @@ export function ChangeBookingSelectionControlsPanel({
80
82
  applyReceiptPaidFloors,
81
83
  ticketUnitFloorByCategory,
82
84
  addOns,
85
+ knownAddOns,
86
+ addOnCatalogLoaded,
83
87
  addOnSelections,
84
88
  onAddOnSelectionsChange,
85
89
  minimumTotalByAddOnId,
@@ -146,6 +150,8 @@ export function ChangeBookingSelectionControlsPanel({
146
150
  ticketUnitFloorByCategory={ticketUnitFloorByCategory}
147
151
  suppressUnitPrices={suppressUnitPrices}
148
152
  addOns={addOns}
153
+ knownAddOns={knownAddOns}
154
+ addOnCatalogLoaded={addOnCatalogLoaded}
149
155
  addOnSelections={addOnSelections}
150
156
  onAddOnSelectionsChange={onAddOnSelectionsChange}
151
157
  minimumTotalByAddOnId={minimumTotalByAddOnId}
@@ -1,6 +1,10 @@
1
1
  import type { AddOn, Availability, ReturnOption } from '../../lib/booking-api';
2
2
  import { AddOnsSection, type AddOnSelection } from './AddOnsSection';
3
3
  import type { Currency } from './CurrencySwitcher';
4
+ import {
5
+ incompatibleAddOnSelections,
6
+ removeAddOnSelections,
7
+ } from './incompatible-add-on-selections';
4
8
  import { TicketSelector, type TicketRate } from './TicketSelector';
5
9
 
6
10
  type TranslationFn = (key: string, params?: Record<string, string | number>) => string;
@@ -29,6 +33,8 @@ export interface ChangeBookingTicketsAndAddOnsPanelProps {
29
33
  applyReceiptPaidFloors: boolean;
30
34
  ticketUnitFloorByCategory?: Map<string, number>;
31
35
  addOns: AddOn[];
36
+ knownAddOns?: AddOn[];
37
+ addOnCatalogLoaded?: boolean;
32
38
  addOnSelections: AddOnSelection[];
33
39
  onAddOnSelectionsChange: AddOnSelectionsChangeHandler;
34
40
  minimumTotalByAddOnId?: Map<string, number>;
@@ -56,12 +62,23 @@ export function ChangeBookingTicketsAndAddOnsPanel({
56
62
  applyReceiptPaidFloors,
57
63
  ticketUnitFloorByCategory,
58
64
  addOns,
65
+ knownAddOns = addOns,
66
+ addOnCatalogLoaded = false,
59
67
  addOnSelections,
60
68
  onAddOnSelectionsChange,
61
69
  minimumTotalByAddOnId,
62
70
  suppressUnitPrices = false,
63
71
  suppressAddOnPrices = false,
64
72
  }: ChangeBookingTicketsAndAddOnsPanelProps) {
73
+ const incompatibleSelections = incompatibleAddOnSelections(
74
+ addOnSelections,
75
+ addOns,
76
+ addOnCatalogLoaded,
77
+ );
78
+ const incompatibleAddOnIds = Array.from(
79
+ new Set(incompatibleSelections.map((selection) => selection.addOnId)),
80
+ );
81
+
65
82
  return (
66
83
  <>
67
84
  <TicketSelector
@@ -97,6 +114,69 @@ export function ChangeBookingTicketsAndAddOnsPanel({
97
114
  suppressPrices={suppressAddOnPrices}
98
115
  />
99
116
  ) : null}
117
+
118
+ {incompatibleAddOnIds.length > 0 ? (
119
+ <div
120
+ className="space-y-3 rounded-lg border border-red-300 bg-red-50 p-4"
121
+ data-testid="incompatible-add-ons"
122
+ >
123
+ <div>
124
+ <h3 className="text-sm font-semibold !text-red-700">
125
+ Add-on unavailable with selected tour
126
+ </h3>
127
+ <p className="mt-1 text-sm text-red-800">
128
+ This booking still contains an add-on that the selected tour does not offer.
129
+ Remove it explicitly before recalculating the amendment.
130
+ </p>
131
+ </div>
132
+ {incompatibleAddOnIds.map((addOnId) => {
133
+ const addOn = knownAddOns.find((candidate) => candidate.addOnId === addOnId);
134
+ const selections = incompatibleSelections.filter(
135
+ (selection) => selection.addOnId === addOnId,
136
+ );
137
+ const quantity = selections.reduce(
138
+ (total, selection) => total + Math.max(1, Number(selection.quantity) || 1),
139
+ 0,
140
+ );
141
+ const variants = selections
142
+ .map((selection) => (
143
+ addOn?.variants.find((variant) => variant.id === selection.variantId)?.label ??
144
+ (selection.variantId ? 'Selected option' : null)
145
+ ))
146
+ .filter((label): label is string => Boolean(label));
147
+ return (
148
+ <div
149
+ key={addOnId}
150
+ className="flex flex-col gap-3 rounded-md border border-red-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between"
151
+ >
152
+ <div className="text-sm">
153
+ <div className="font-medium text-stone-900">{addOn?.name ?? 'Selected add-on'}</div>
154
+ <div className="text-stone-600">
155
+ {variants.length > 0 ? variants.join(', ') : `Quantity ${quantity}`}
156
+ </div>
157
+ </div>
158
+ {isAdmin ? (
159
+ <button
160
+ type="button"
161
+ className="rounded-md border border-red-500 bg-white px-3 py-2 text-sm font-semibold text-red-800 hover:bg-red-100"
162
+ onClick={() => {
163
+ onAddOnSelectionsChange((current) =>
164
+ removeAddOnSelections(current, addOnId),
165
+ );
166
+ }}
167
+ >
168
+ Remove add-on
169
+ </button>
170
+ ) : (
171
+ <p className="text-sm text-red-800">
172
+ Choose another tour to keep this add-on.
173
+ </p>
174
+ )}
175
+ </div>
176
+ );
177
+ })}
178
+ </div>
179
+ ) : null}
100
180
  </>
101
181
  );
102
182
  }