@ticketboothapp/booking 1.2.141 → 1.2.143

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.141",
3
+ "version": "1.2.143",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -155,7 +155,9 @@ const checkoutPanelPropKeys = [
155
155
  'receiptTotal',
156
156
  'originalReceipt',
157
157
  'priceSummaryLinesIncludeTaxRow',
158
- 'isServerAmendmentQuote',
158
+ 'serverAmendmentLines',
159
+ 'serverAmountDue',
160
+ 'serverUpdatedTotal',
159
161
  'isTaxIncludedInPrice',
160
162
  'taxRate',
161
163
  'currency',
@@ -971,7 +971,18 @@ export function AdminChangeBookingFlow({
971
971
  receiptTax: adminFeAuthoritativeReceipt.tax,
972
972
  receiptTotal: adminFeAuthoritativeReceipt.total,
973
973
  originalReceipt,
974
- isServerAmendmentQuote: latestChangeQuote?.pricingVersion === 'booking-amendment-v1',
974
+ serverAmendmentLines:
975
+ latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
976
+ ? latestChangeQuote.serverPreview?.priceSummaryLines ?? []
977
+ : null,
978
+ serverAmountDue:
979
+ latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
980
+ ? latestChangeQuote.serverPreview?.amountDue ?? null
981
+ : null,
982
+ serverUpdatedTotal:
983
+ latestChangeQuote?.pricingVersion === 'booking-amendment-v1'
984
+ ? latestChangeQuote.serverPreview?.totalNewBooking ?? null
985
+ : null,
975
986
  ...{ priceSummaryLinesIncludeTaxRow, isTaxIncludedInPrice, showProviderPricingInlineEditor },
976
987
  taxRate: pricingConfig?.taxRate,
977
988
  ...{ providerPricingUi, providerQuotedLines },
@@ -30,7 +30,9 @@ export interface AdminChangeCheckoutPanelProps {
30
30
  receiptTotal: number;
31
31
  originalReceipt: ChangeBookingFlowProps['originalReceipt'];
32
32
  priceSummaryLinesIncludeTaxRow: boolean;
33
- isServerAmendmentQuote?: boolean;
33
+ serverAmendmentLines: PriceSummaryLine[] | null;
34
+ serverAmountDue: number | null;
35
+ serverUpdatedTotal: number | null;
34
36
  isTaxIncludedInPrice: boolean;
35
37
  taxRate?: number;
36
38
  currency: Currency;
@@ -100,7 +102,9 @@ export function AdminChangeCheckoutPanel({
100
102
  receiptTotal,
101
103
  originalReceipt,
102
104
  priceSummaryLinesIncludeTaxRow,
103
- isServerAmendmentQuote,
105
+ serverAmendmentLines,
106
+ serverAmountDue,
107
+ serverUpdatedTotal,
104
108
  isTaxIncludedInPrice,
105
109
  taxRate,
106
110
  currency,
@@ -183,26 +187,16 @@ export function AdminChangeCheckoutPanel({
183
187
  isAdmin && originalReceipt ? (
184
188
  <AdminChangeReceiptComparison
185
189
  originalReceipt={originalReceipt}
186
- newReceipt={{
187
- subtotal: receiptSubtotal,
188
- tax: receiptTax,
189
- total: receiptTotal,
190
- }}
191
- newPriceSummaryLines={priceSummaryLines}
192
- newPriceSummaryLinesIncludeTaxRow={priceSummaryLinesIncludeTaxRow}
193
- isServerAmendmentQuote={isServerAmendmentQuote}
194
- amountDue={changeFlowAmountDue}
190
+ amendmentLines={serverAmendmentLines}
191
+ amountDue={serverAmountDue}
192
+ updatedTotal={serverUpdatedTotal}
193
+ selectionChanged={hasEffectiveChangeSelection}
195
194
  amountDueLabel={totalSummaryLabel}
196
195
  currency={currency}
197
196
  locale={locale}
198
197
  t={t}
199
198
  taxRate={taxRate}
200
- newReceiptAdjustments={pricingAdjustments}
201
- lineAmountInputs={lineAmountInputs}
202
- onLineAmountInputChange={onLineAmountInputChange}
203
- onLineAmountInputBlur={onLineAmountInputBlur}
204
- onLineAmountReset={onLineAmountReset}
205
- lineLabelInputs={lineLabelInputs}
199
+ adjustments={pricingAdjustments}
206
200
  />
207
201
  ) : null;
208
202
 
@@ -1,6 +1,4 @@
1
- import { useMemo, type ReactNode } from 'react';
2
- import type { AdminFeAuthoritativeReceipt } from '../../lib/booking-api';
3
- import { roundMoney } from '../../lib/booking/change-flow-pricing';
1
+ import type { ReactNode } from 'react';
4
2
  import { mapQuoteLineItemsToPriceSummaryLines } from '../../lib/booking/change-booking-server-preview';
5
3
  import { formatCurrencyAmount } from '../../lib/currency';
6
4
  import type { Locale } from '../../lib/booking/i18n/config';
@@ -13,240 +11,63 @@ type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
13
11
 
14
12
  export interface AdminChangeReceiptComparisonProps {
15
13
  originalReceipt: OriginalReceipt;
16
- newReceipt: Pick<AdminFeAuthoritativeReceipt, 'subtotal' | 'tax' | 'total'>;
17
- newPriceSummaryLines: PriceSummaryLine[];
18
- newPriceSummaryLinesIncludeTaxRow: boolean;
19
- isServerAmendmentQuote?: boolean;
20
- amountDue: number;
14
+ /** Amendment-only rows returned by the Pricing V2 server quote. */
15
+ amendmentLines: PriceSummaryLine[] | null;
16
+ /** Signed server balance delta. Never calculated by this component. */
17
+ amountDue: number | null;
18
+ /** Full resulting booking total returned by the same server quote. */
19
+ updatedTotal: number | null;
20
+ selectionChanged: boolean;
21
21
  amountDueLabel: string;
22
22
  currency: Currency;
23
23
  locale: string;
24
24
  t: TranslationFn;
25
25
  taxRate?: number;
26
- newReceiptAdjustments?: ReactNode;
27
- lineAmountInputs?: Record<string, string>;
28
- onLineAmountInputChange?: (lineKey: string, value: string) => void;
29
- onLineAmountInputBlur?: (lineKey: string) => void;
30
- onLineAmountReset?: (lineKey: string) => void;
31
- lineLabelInputs?: Record<string, string>;
26
+ adjustments?: ReactNode;
32
27
  }
33
28
 
34
29
  function normalizeLocale(locale: string): Locale {
35
30
  return locale === 'fr' ? 'fr' : 'en';
36
31
  }
37
32
 
33
+ function originalLines(receipt: OriginalReceipt): PriceSummaryLine[] {
34
+ return receipt.lineItems?.length
35
+ ? mapQuoteLineItemsToPriceSummaryLines(receipt.lineItems)
36
+ : [];
37
+ }
38
+
38
39
  function hasTaxLine(lines: PriceSummaryLine[]): boolean {
39
40
  return lines.some(
40
41
  (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
41
42
  );
42
43
  }
43
44
 
44
- function mapOriginalReceiptLines(originalReceipt: OriginalReceipt): PriceSummaryLine[] {
45
- if (!originalReceipt.lineItems?.length) return [];
46
- return mapQuoteLineItemsToPriceSummaryLines(originalReceipt.lineItems);
47
- }
48
-
49
- function lineIdentity(line: PriceSummaryLine): string {
50
- const normalizedLabel = (value: string) =>
51
- value
52
- .replace(/\s*\([^)]*(?:people|person|x\s*\d+)[^)]*\)\s*$/i, '')
53
- .replace(/\s+/g, ' ')
54
- .trim()
55
- .toUpperCase();
56
- return line.kind === 'ticket'
57
- ? `ticket:${line.category.trim().toUpperCase()}`
58
- : `line:${String(line.type ?? '').trim().toUpperCase()}:${normalizedLabel(line.label)}`;
59
- }
60
-
61
- function lineAmount(line: PriceSummaryLine): number {
62
- return line.kind === 'ticket' ? line.itemTotal : line.amount;
63
- }
64
-
65
- function lineQuantity(line: PriceSummaryLine): number | null {
66
- return line.kind === 'ticket' ? line.qty : line.quantity ?? null;
67
- }
68
-
69
- function amendmentDeltaLines(
70
- originalLines: PriceSummaryLine[],
71
- proposedLines: PriceSummaryLine[],
72
- ): PriceSummaryLine[] {
73
- const remainingOriginal = new Map<string, { amount: number; quantity: number | null }>();
74
- for (const line of originalLines) {
75
- const key = lineIdentity(line);
76
- const previous = remainingOriginal.get(key);
77
- remainingOriginal.set(key, {
78
- amount: roundMoney((previous?.amount ?? 0) + lineAmount(line)),
79
- quantity:
80
- previous?.quantity == null && lineQuantity(line) == null
81
- ? null
82
- : (previous?.quantity ?? 0) + (lineQuantity(line) ?? 0),
83
- });
84
- }
85
-
86
- return proposedLines.flatMap((line): PriceSummaryLine[] => {
87
- const original = remainingOriginal.get(lineIdentity(line));
88
- const proposedQuantity = lineQuantity(line);
89
- const quantity =
90
- proposedQuantity == null && original?.quantity == null
91
- ? null
92
- : (proposedQuantity ?? 0) - (original?.quantity ?? 0);
93
- const proposedAmount = lineAmount(line);
94
- const amount = roundMoney(
95
- quantity != null && quantity > 0 && proposedQuantity != null && proposedQuantity > 0
96
- ? (proposedAmount / proposedQuantity) * quantity
97
- : proposedAmount - (original?.amount ?? 0),
98
- );
99
- if (Math.abs(amount) < 0.005 && (quantity == null || quantity === 0)) return [];
100
-
101
- if (line.kind === 'ticket') {
102
- return [{
103
- ...line,
104
- qty: Math.abs(quantity ?? 0) || line.qty,
105
- itemTotal: amount,
106
- editable: false,
107
- lineKey: undefined,
108
- }];
109
- }
110
- const isTax = String(line.type ?? '').toUpperCase() === 'TAX';
111
- return [{
112
- ...line,
113
- label: isTax
114
- ? (amount >= 0 ? 'Additional tax' : 'Tax reduction')
115
- : line.label.replace(/\s*\([^)]*(?:people|person|x\s*\d+)[^)]*\)\s*$/i, '').trim(),
116
- amount,
117
- quantity: quantity == null ? null : Math.abs(quantity),
118
- editable: false,
119
- lineKey: undefined,
120
- }];
121
- });
122
- }
123
-
124
45
  export function AdminChangeReceiptComparison({
125
46
  originalReceipt,
126
- newReceipt,
127
- newPriceSummaryLines,
128
- isServerAmendmentQuote = false,
47
+ amendmentLines,
48
+ amountDue,
49
+ updatedTotal,
50
+ selectionChanged,
129
51
  amountDueLabel,
130
52
  currency,
131
53
  locale,
132
54
  t,
133
55
  taxRate,
134
- newReceiptAdjustments,
56
+ adjustments,
135
57
  }: AdminChangeReceiptComparisonProps) {
136
58
  const displayLocale = normalizeLocale(locale);
137
- const originalReceiptLines = useMemo(
138
- () => mapOriginalReceiptLines(originalReceipt),
139
- [originalReceipt],
140
- );
141
- const originalLinesIncludeTaxRow = hasTaxLine(originalReceiptLines);
142
- const calculatedChangeLines = useMemo(() => {
143
- if (!isServerAmendmentQuote) {
144
- return amendmentDeltaLines(originalReceiptLines, newPriceSummaryLines);
145
- }
146
- const nonTax = newPriceSummaryLines.filter(
147
- (line) => !(line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'),
148
- );
149
- const tax = roundMoney(
150
- newPriceSummaryLines.reduce(
151
- (sum, line) =>
152
- line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
153
- ? sum + line.amount
154
- : sum,
155
- 0,
156
- ),
157
- );
158
- return Math.abs(tax) < 0.005
159
- ? nonTax
160
- : [
161
- ...nonTax,
162
- {
163
- kind: 'line' as const,
164
- label: tax >= 0 ? 'Additional tax' : 'Tax reduction',
165
- amount: tax,
166
- type: 'TAX',
167
- },
168
- ];
169
- }, [isServerAmendmentQuote, newPriceSummaryLines, originalReceiptLines]);
170
- const nonTaxChangeSubtotal = roundMoney(
171
- calculatedChangeLines.reduce(
172
- (sum, line) =>
173
- line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
174
- ? sum
175
- : sum + lineAmount(line),
176
- 0,
177
- ),
178
- );
179
- const receiptTaxDelta = roundMoney(
180
- isServerAmendmentQuote
181
- ? calculatedChangeLines.reduce(
182
- (sum, line) =>
183
- line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
184
- ? sum + line.amount
185
- : sum,
186
- 0,
187
- )
188
- : taxRate != null && nonTaxChangeSubtotal > 0
189
- ? nonTaxChangeSubtotal * taxRate
190
- : newReceipt.tax - originalReceipt.tax,
191
- );
192
- const changeLines = useMemo(() => {
193
- if (hasTaxLine(calculatedChangeLines) || Math.abs(receiptTaxDelta) < 0.005) {
194
- return calculatedChangeLines;
195
- }
196
- return [
197
- ...calculatedChangeLines,
198
- {
199
- kind: 'line' as const,
200
- label: receiptTaxDelta >= 0 ? 'Additional tax' : 'Tax reduction',
201
- amount: receiptTaxDelta,
202
- type: 'TAX',
203
- },
204
- ];
205
- }, [calculatedChangeLines, receiptTaxDelta]);
206
- const changeLinesIncludeTaxRow = hasTaxLine(changeLines);
207
- const changeTax = roundMoney(
208
- changeLines.reduce(
209
- (sum, line) =>
210
- line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
211
- ? sum + line.amount
212
- : sum,
213
- 0,
214
- ),
215
- );
216
- const displayedAmountDue = roundMoney(changeLines.reduce((sum, line) => sum + lineAmount(line), 0));
217
- const displayedUpdatedTotal = roundMoney(originalReceipt.total + displayedAmountDue);
218
- const displayedUpdatedSubtotal = roundMoney(originalReceipt.subtotal + nonTaxChangeSubtotal);
219
- const displayedUpdatedTax = roundMoney(originalReceipt.tax + changeTax);
59
+ const existingLines = originalLines(originalReceipt);
220
60
  const originalCurrency = originalReceipt.currency ?? currency;
221
- const originalReceiptLinesWithComparableBreakdowns = useMemo(
222
- () =>
223
- originalReceiptLines.map((line) =>
224
- line.kind === 'ticket' && line.qty > 0
225
- ? {
226
- ...line,
227
- breakdown: line.breakdown ?? {
228
- lineItems: [],
229
- subtotalAfterAdjustments: roundMoney(line.itemTotal / line.qty),
230
- feeLines: [],
231
- taxRate: 0,
232
- taxAmount: 0,
233
- isTaxIncluded: false,
234
- finalPrice: roundMoney(line.itemTotal / line.qty),
235
- },
236
- }
237
- : line,
238
- ),
239
- [originalReceiptLines],
240
- );
61
+ const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
241
62
  const amountDueClass =
242
- displayedAmountDue < -0.005
63
+ amountDue != null && amountDue < -0.005
243
64
  ? 'text-emerald-700'
244
- : displayedAmountDue > 0.005
65
+ : amountDue != null && amountDue > 0.005
245
66
  ? 'text-stone-900'
246
67
  : 'text-stone-700';
247
68
 
248
69
  return (
249
- <div className="space-y-3 min-w-0 overflow-visible">
70
+ <div className="min-w-0 space-y-3 overflow-visible">
250
71
  <div className="grid gap-4 md:grid-cols-2">
251
72
  <section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
252
73
  <div className="mb-3 flex items-center justify-between gap-3">
@@ -256,89 +77,77 @@ export function AdminChangeReceiptComparison({
256
77
  </span>
257
78
  </div>
258
79
  <PriceSummary
259
- lines={originalReceiptLinesWithComparableBreakdowns}
80
+ lines={existingLines}
260
81
  total={originalReceipt.total}
261
82
  currency={originalCurrency}
262
83
  locale={displayLocale}
263
84
  subtotal={originalReceipt.subtotal}
264
- taxAmount={!originalLinesIncludeTaxRow ? originalReceipt.tax : 0}
85
+ taxAmount={hasTaxLine(existingLines) ? 0 : originalReceipt.tax}
265
86
  taxRate={taxRate}
266
87
  size="sm"
267
88
  t={t}
268
89
  />
269
90
  </section>
91
+
270
92
  <section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
271
93
  <div className="mb-3 flex items-center justify-between gap-3">
272
94
  <div>
273
95
  <h3 className="text-sm font-semibold text-stone-900">This change</h3>
274
- <p className="mt-0.5 text-xs text-stone-500">Only newly charged, credited, or repriced value</p>
96
+ <p className="mt-0.5 text-xs text-stone-500">Server-priced amendment</p>
275
97
  </div>
276
98
  <span className="shrink-0 text-xs font-medium text-stone-500">
277
- {formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
99
+ {amountDue == null ? '—' : formatCurrencyAmount(amountDue, currency, displayLocale)}
278
100
  </span>
279
101
  </div>
280
- {changeLines.length === 0 && Math.abs(displayedAmountDue) < 0.005 ? (
102
+ {!selectionChanged ? (
103
+ <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
104
+ <p className="text-sm font-medium text-stone-700">No changes selected</p>
105
+ <p className="mt-1 text-xs text-stone-500">Change tickets or another booking option to request a price.</p>
106
+ </div>
107
+ ) : !quoteReady ? (
108
+ <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
109
+ <p className="text-sm font-medium text-stone-700">Waiting for server price…</p>
110
+ </div>
111
+ ) : amendmentLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
281
112
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
282
113
  <p className="text-sm font-medium text-stone-700">No pricing changes</p>
283
- <p className="mt-1 text-xs text-stone-500">The selected booking details match the existing purchase.</p>
284
114
  </div>
285
115
  ) : (
286
116
  <PriceSummary
287
- lines={changeLines}
288
- total={displayedAmountDue}
117
+ lines={amendmentLines}
118
+ total={amountDue}
289
119
  currency={currency}
290
120
  locale={displayLocale}
291
- subtotal={nonTaxChangeSubtotal}
292
- taxAmount={!changeLinesIncludeTaxRow ? changeTax : 0}
293
- taxRate={taxRate}
294
121
  size="sm"
295
122
  t={t}
296
123
  />
297
124
  )}
298
125
  </section>
299
126
  </div>
300
- <section className="min-w-0 overflow-visible rounded-lg border border-stone-300 bg-white p-3 shadow-sm">
301
- <div className="mb-3 flex items-center justify-between gap-3">
127
+
128
+ {quoteReady ? (
129
+ <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">
302
130
  <div>
303
- <h3 className="text-sm font-semibold text-stone-900">Updated booking receipt</h3>
304
- <p className="mt-0.5 text-xs text-stone-500">Read-only pricing result; historical and added cohorts remain separate when supplied by the server</p>
131
+ <h3 className="text-sm font-semibold text-stone-900">Updated booking total</h3>
132
+ <p className="mt-0.5 text-xs text-stone-500">Authoritative total returned by the server quote</p>
305
133
  </div>
306
- <span className="shrink-0 text-xs font-medium text-stone-500">
307
- {formatCurrencyAmount(displayedUpdatedTotal, currency, displayLocale)}
134
+ <span className="text-lg font-semibold tabular-nums text-stone-900">
135
+ {formatCurrencyAmount(updatedTotal, currency, displayLocale)}
308
136
  </span>
309
- </div>
310
- <PriceSummary
311
- lines={[
312
- ...originalReceiptLines.filter(
313
- (line) => !(line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'),
314
- ),
315
- ...changeLines.filter(
316
- (line) => !(line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'),
317
- ),
318
- ].map((line) => ({ ...line, editable: false, lineKey: undefined }))}
319
- total={displayedUpdatedTotal}
320
- currency={currency}
321
- locale={displayLocale}
322
- subtotal={displayedUpdatedSubtotal}
323
- taxAmount={displayedUpdatedTax}
324
- taxRate={taxRate}
325
- size="sm"
326
- t={t}
327
- />
328
- </section>
329
- {newReceiptAdjustments ? (
137
+ </section>
138
+ ) : null}
139
+
140
+ {adjustments ? (
330
141
  <section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
331
- <div className="mb-2">
332
- <h3 className="text-sm font-semibold text-stone-900">Admin adjustments</h3>
333
- <p className="mt-0.5 text-xs text-stone-500">Optional structured charges or credits applied to this amendment</p>
334
- </div>
335
- {newReceiptAdjustments}
142
+ <h3 className="mb-2 text-sm font-semibold text-stone-900">Admin adjustments</h3>
143
+ {adjustments}
336
144
  </section>
337
145
  ) : null}
146
+
338
147
  <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
339
148
  <span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
340
149
  <span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
341
- {formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
150
+ {!selectionChanged || amountDue == null ? '—' : formatCurrencyAmount(amountDue, currency, displayLocale)}
342
151
  </span>
343
152
  </div>
344
153
  </div>