@ticketboothapp/booking 1.2.60 → 1.2.62

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.
@@ -0,0 +1,268 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { formatCurrencyAmount } from '../../lib/currency';
5
+ import { roundMoney } from '../../lib/booking/change-flow-pricing';
6
+ import type { ChangeBookingPricingDriftRow } from '../../lib/booking/change-booking-pricing-drift';
7
+ import type { ChangeBookingQuoteTicketPricingTrace } from '../../lib/booking-api';
8
+ import type { Currency } from './CurrencySwitcher';
9
+ import type { Locale } from '../../lib/booking/i18n/config';
10
+
11
+ export type ChangeBookingDriftTicketCartRow = {
12
+ category: string;
13
+ qty: number;
14
+ /** List/catalog unit from the current ticket picker (major units). */
15
+ listUnitMajor: number;
16
+ /** Effective unit after change-flow rules (line total ÷ qty). */
17
+ effectiveUnitMajor: number;
18
+ lineTotalMajor: number;
19
+ };
20
+
21
+ function fmt(amount: number | null | undefined, currency: Currency, locale: Locale): string {
22
+ if (amount == null || !Number.isFinite(amount)) return '—';
23
+ return formatCurrencyAmount(amount, currency, locale);
24
+ }
25
+
26
+ export function ChangeBookingPricingDriftPanel(props: {
27
+ rows: ChangeBookingPricingDriftRow[];
28
+ /** Client / app rolled-up new booking total (major units). */
29
+ clientTotal?: number;
30
+ /** Server price-check total (major units). */
31
+ serverTotal?: number;
32
+ /** clientTotal − serverTotal when both defined. */
33
+ totalDelta?: number | null;
34
+ currency: Currency;
35
+ locale: Locale;
36
+ /** Optional title above the table. */
37
+ title?: ReactNode;
38
+ /** Extra note under the table (e.g. data source for temporary BE comparison payloads). */
39
+ footnote?: ReactNode;
40
+ /** Per-category ticket math from the live app cart (qty, list unit, effective unit, line total). */
41
+ ticketCartDetail?: ChangeBookingDriftTicketCartRow[];
42
+ /** Optional BE snapshot: locked vs live units and Rule A/B branch (when API sends `ticketPricingTrace`). */
43
+ serverTicketPricingTrace?: ChangeBookingQuoteTicketPricingTrace | null;
44
+ }): ReactNode {
45
+ const {
46
+ rows,
47
+ clientTotal,
48
+ serverTotal,
49
+ totalDelta,
50
+ currency,
51
+ locale,
52
+ title = 'Where the difference comes from (app vs server price check)',
53
+ footnote,
54
+ ticketCartDetail,
55
+ serverTicketPricingTrace,
56
+ } = props;
57
+
58
+ const showTotals =
59
+ clientTotal != null &&
60
+ serverTotal != null &&
61
+ totalDelta != null &&
62
+ Math.abs(totalDelta) >= 0.005;
63
+
64
+ if (rows.length === 0 && !showTotals) return null;
65
+
66
+ const showTicketCols = rows.some((r) => r.key.startsWith('ticket:'));
67
+
68
+ function unitFmt(amount: number | null | undefined, qty: number | null | undefined): string {
69
+ if (amount == null || qty == null || qty <= 0 || !Number.isFinite(amount)) return '—';
70
+ return fmt(roundMoney(amount / qty), currency, locale);
71
+ }
72
+
73
+ return (
74
+ <div className="mt-3 rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm shadow-sm">
75
+ <div className="mb-2 font-medium text-stone-800">{title}</div>
76
+ <div className="mb-3 rounded-md border border-sky-100 bg-sky-50/80 px-2.5 py-2 text-[11px] leading-snug text-stone-800">
77
+ <span className="font-semibold text-stone-900">Why these differ — </span>
78
+ List = catalog for this slot; effective = per-seat charge after change rules (floors, date/option). When the API
79
+ sends <span className="font-mono text-[10px]">ticketPricingTrace</span>, compare BE live vs locked units below.
80
+ Tiny drift can still be rounding (per ticket vs rolled-up line).
81
+ </div>
82
+ {ticketCartDetail && ticketCartDetail.length > 0 ? (
83
+ <div className="mb-4 rounded-md border border-stone-100 bg-stone-50/80 px-2 py-2">
84
+ <div className="mb-1.5 text-xs font-medium text-stone-700">In-app ticket math (change flow)</div>
85
+ <div className="overflow-x-auto">
86
+ <table className="w-full min-w-[320px] border-collapse text-left text-[11px] text-stone-700">
87
+ <thead>
88
+ <tr className="border-b border-stone-200 text-[10px] uppercase tracking-wide text-stone-500">
89
+ <th className="py-1 pr-2 font-medium">Category</th>
90
+ <th className="py-1 pr-2 text-right font-medium">Qty</th>
91
+ <th className="py-1 pr-2 text-right font-medium">List / ticket</th>
92
+ <th className="py-1 pr-2 text-right font-medium">Effective / ticket</th>
93
+ <th className="py-1 text-right font-medium">Line total</th>
94
+ </tr>
95
+ </thead>
96
+ <tbody>
97
+ {ticketCartDetail.map((r) => (
98
+ <tr key={r.category} className="border-t border-stone-100">
99
+ <td className="py-1.5 pr-2">{r.category}</td>
100
+ <td className="py-1.5 pr-2 text-right tabular-nums">{r.qty}</td>
101
+ <td className="py-1.5 pr-2 text-right tabular-nums">{fmt(r.listUnitMajor, currency, locale)}</td>
102
+ <td className="py-1.5 pr-2 text-right tabular-nums">{fmt(r.effectiveUnitMajor, currency, locale)}</td>
103
+ <td className="py-1.5 text-right tabular-nums font-medium">{fmt(r.lineTotalMajor, currency, locale)}</td>
104
+ </tr>
105
+ ))}
106
+ </tbody>
107
+ </table>
108
+ </div>
109
+ <p className="mt-1.5 text-[10px] leading-snug text-stone-500">
110
+ List uses current picker pricing; effective is ticket line total ÷ qty (receipt floors / option rules).
111
+ </p>
112
+ </div>
113
+ ) : null}
114
+ {serverTicketPricingTrace && serverTicketPricingTrace.ticketLines.length > 0 ? (
115
+ <div className="mb-4 rounded-md border border-emerald-100 bg-emerald-50/70 px-2 py-2">
116
+ <div className="mb-1.5 text-xs font-medium text-emerald-950">Server ticket math (BE trace)</div>
117
+ <p className="mb-2 text-[10px] leading-snug text-emerald-950/90">
118
+ Same parent product:{' '}
119
+ <span className="font-semibold">{serverTicketPricingTrace.sameParentProduct ? 'yes' : 'no'}</span>
120
+ {' · '}
121
+ Unchanged itinerary:{' '}
122
+ <span className="font-semibold">{serverTicketPricingTrace.unchangedItinerary ? 'yes' : 'no'}</span>
123
+ <br />
124
+ Parents{' '}
125
+ <span className="font-mono text-[10px]">
126
+ {serverTicketPricingTrace.bookingParentProductId} → {serverTicketPricingTrace.requestedParentProductId}
127
+ </span>
128
+ {' · '}
129
+ Options{' '}
130
+ <span className="font-mono text-[10px]">
131
+ {serverTicketPricingTrace.bookingOptionId} → {serverTicketPricingTrace.requestedOptionId}
132
+ </span>
133
+ </p>
134
+ {serverTicketPricingTrace.lockedTicketUnitPriceByCategory &&
135
+ Object.keys(serverTicketPricingTrace.lockedTicketUnitPriceByCategory).length > 0 ? (
136
+ <p className="mb-2 font-mono text-[10px] text-emerald-950/90">
137
+ Locked from receipt:{' '}
138
+ {Object.entries(serverTicketPricingTrace.lockedTicketUnitPriceByCategory)
139
+ .map(([k, v]) => `${k}=${fmt(v, currency, locale)}`)
140
+ .join(' · ')}
141
+ </p>
142
+ ) : null}
143
+ <div className="overflow-x-auto">
144
+ <table className="w-full min-w-[360px] border-collapse text-left text-[11px] text-emerald-950">
145
+ <thead>
146
+ <tr className="border-b border-emerald-200 text-[10px] uppercase tracking-wide text-emerald-800/90">
147
+ <th className="py-1 pr-2 font-medium">Cat</th>
148
+ <th className="py-1 pr-2 font-medium">Rule</th>
149
+ <th className="py-1 pr-2 text-right font-medium">Live/u</th>
150
+ <th className="py-1 pr-2 text-right font-medium">Lock/u</th>
151
+ <th className="py-1 pr-2 text-right font-medium">Prot+incr</th>
152
+ <th className="py-1 text-right font-medium">Line amt</th>
153
+ </tr>
154
+ </thead>
155
+ <tbody>
156
+ {serverTicketPricingTrace.ticketLines.map((ln, i) => (
157
+ <tr key={`${ln.category}-${ln.rule}-${i}`} className="border-t border-emerald-100">
158
+ <td className="py-1.5 pr-2">{ln.category}</td>
159
+ <td className="max-w-[140px] py-1.5 pr-2 font-mono text-[10px] leading-tight">{ln.rule}</td>
160
+ <td className="py-1.5 pr-2 text-right tabular-nums">{fmt(ln.liveUnitMajorUnits, currency, locale)}</td>
161
+ <td className="py-1.5 pr-2 text-right tabular-nums">
162
+ {ln.lockedUnitMajorUnits != null ? fmt(ln.lockedUnitMajorUnits, currency, locale) : '—'}
163
+ </td>
164
+ <td className="py-1.5 pr-2 text-right tabular-nums text-emerald-900/90">
165
+ {ln.protectedQty}+{ln.incrementalQty}
166
+ </td>
167
+ <td className="py-1.5 text-right tabular-nums font-medium">
168
+ {fmt(ln.ticketLineAmountMajorUnits, currency, locale)}
169
+ </td>
170
+ </tr>
171
+ ))}
172
+ </tbody>
173
+ </table>
174
+ </div>
175
+ </div>
176
+ ) : null}
177
+ <div className="overflow-x-auto">
178
+ <table className="w-full min-w-[280px] border-collapse text-left text-xs text-stone-700">
179
+ <thead>
180
+ <tr className="border-b border-stone-200 text-[11px] uppercase tracking-wide text-stone-500">
181
+ <th className="py-1.5 pr-2 font-medium">Line</th>
182
+ {showTicketCols ? (
183
+ <>
184
+ <th className="py-1.5 pr-2 text-right font-medium">Qty</th>
185
+ <th className="py-1.5 pr-2 text-right font-medium">App / ticket</th>
186
+ <th className="py-1.5 pr-2 text-right font-medium">Srv / ticket</th>
187
+ </>
188
+ ) : null}
189
+ <th className="py-1.5 pr-2 text-right font-medium">In app</th>
190
+ <th className="py-1.5 pr-2 text-right font-medium">Server</th>
191
+ <th className="py-1.5 text-right font-medium">Δ</th>
192
+ </tr>
193
+ </thead>
194
+ <tbody>
195
+ {rows.map((r) => {
196
+ const highlight = r.delta != null && Math.abs(r.delta) >= 0.005;
197
+ const isTicketRow = r.key.startsWith('ticket:');
198
+ const qtyDisp =
199
+ r.clientQty != null && r.clientQty > 0
200
+ ? r.clientQty
201
+ : r.serverQty != null && r.serverQty > 0
202
+ ? r.serverQty
203
+ : null;
204
+ return (
205
+ <tr
206
+ key={r.key}
207
+ className={
208
+ highlight ? 'border-t border-stone-100 bg-amber-50/80' : 'border-t border-stone-100'
209
+ }
210
+ >
211
+ <td className="max-w-[200px] py-1.5 pr-2 align-top">{r.label}</td>
212
+ {showTicketCols ? (
213
+ <>
214
+ <td className="whitespace-nowrap py-1.5 pr-2 text-right tabular-nums text-stone-600">
215
+ {isTicketRow && qtyDisp != null ? qtyDisp : '—'}
216
+ </td>
217
+ <td className="whitespace-nowrap py-1.5 pr-2 text-right tabular-nums text-stone-600">
218
+ {isTicketRow ? unitFmt(r.clientAmount, r.clientQty ?? qtyDisp) : '—'}
219
+ </td>
220
+ <td className="whitespace-nowrap py-1.5 pr-2 text-right tabular-nums text-stone-600">
221
+ {isTicketRow ? unitFmt(r.serverAmount, r.serverQty ?? qtyDisp) : '—'}
222
+ </td>
223
+ </>
224
+ ) : null}
225
+ <td className="whitespace-nowrap py-1.5 pr-2 text-right tabular-nums">
226
+ {fmt(r.clientAmount, currency, locale)}
227
+ </td>
228
+ <td className="whitespace-nowrap py-1.5 pr-2 text-right tabular-nums">
229
+ {fmt(r.serverAmount, currency, locale)}
230
+ </td>
231
+ <td
232
+ className={`whitespace-nowrap py-1.5 text-right tabular-nums ${
233
+ highlight ? 'font-medium text-amber-950' : 'text-stone-500'
234
+ }`}
235
+ >
236
+ {r.delta != null && Number.isFinite(r.delta) ? fmt(r.delta, currency, locale) : '—'}
237
+ </td>
238
+ </tr>
239
+ );
240
+ })}
241
+ {showTotals ? (
242
+ <tr className="border-t-2 border-stone-300 bg-stone-50 font-semibold text-stone-900">
243
+ <td className="py-2 pr-2">New booking total</td>
244
+ {showTicketCols ? (
245
+ <>
246
+ <td className="py-2 pr-2" />
247
+ <td className="py-2 pr-2" />
248
+ <td className="py-2 pr-2" />
249
+ </>
250
+ ) : null}
251
+ <td className="py-2 pr-2 text-right tabular-nums">{fmt(clientTotal, currency, locale)}</td>
252
+ <td className="py-2 pr-2 text-right tabular-nums">{fmt(serverTotal, currency, locale)}</td>
253
+ <td className="py-2 text-right tabular-nums text-amber-950">{fmt(totalDelta ?? null, currency, locale)}</td>
254
+ </tr>
255
+ ) : null}
256
+ </tbody>
257
+ </table>
258
+ </div>
259
+ <p className="mt-2 text-[11px] leading-snug text-stone-500">
260
+ Δ is <span className="font-medium">in app minus server</span>. Rows highlighted when the difference is at least
261
+ one cent.
262
+ </p>
263
+ {footnote ? (
264
+ <p className="mt-1 text-[11px] leading-snug text-stone-600">{footnote}</p>
265
+ ) : null}
266
+ </div>
267
+ );
268
+ }
@@ -71,6 +71,8 @@ interface CheckoutFormProps {
71
71
  hideSubmitButton?: boolean;
72
72
  /** Extra disabled state (e.g. change-booking quote still loading) without showing reservation spinner */
73
73
  submitDisabled?: boolean;
74
+ /** When set, replaces the price summary block entirely (e.g. self-serve change — no FE dollar amounts). */
75
+ replacePriceSummary?: ReactNode;
74
76
  attributionSummary?: string;
75
77
  attributionConfirmLabel?: string;
76
78
  attributionConfirmed?: boolean;
@@ -125,6 +127,7 @@ export function CheckoutForm({
125
127
  submitLabel,
126
128
  hideSubmitButton = false,
127
129
  submitDisabled = false,
130
+ replacePriceSummary,
128
131
  attributionSummary,
129
132
  attributionConfirmLabel,
130
133
  attributionConfirmed = false,
@@ -141,25 +144,32 @@ export function CheckoutForm({
141
144
  return (
142
145
  <div className={styles.section}>
143
146
  <div className={styles.summaryWrapper}>
144
- <PriceSummary
145
- lines={priceSummaryLines}
146
- total={totalPrice}
147
- currency={currency}
148
- locale={locale as 'en' | 'fr'}
149
- subtotal={subtotal}
150
- taxAmount={taxAmount}
151
- taxRate={taxRate}
152
- size="sm"
153
- t={t}
154
- extraBetweenTaxAndTotal={extraBetweenTaxAndTotal}
155
- totalLabel={totalSummaryLabel}
156
- extraAfterTotal={extraAfterTotal}
157
- lineAmountInputs={lineAmountInputs}
158
- onLineAmountInputChange={onLineAmountInputChange}
159
- onLineAmountInputBlur={onLineAmountInputBlur}
160
- onLineAmountReset={onLineAmountReset}
161
- extraBeforeSubtotal={extraBeforeSubtotal}
162
- />
147
+ {replacePriceSummary ? (
148
+ <>
149
+ {replacePriceSummary}
150
+ <div className="mt-4">{extraBetweenTaxAndTotal}</div>
151
+ </>
152
+ ) : (
153
+ <PriceSummary
154
+ lines={priceSummaryLines}
155
+ total={totalPrice}
156
+ currency={currency}
157
+ locale={locale as 'en' | 'fr'}
158
+ subtotal={subtotal}
159
+ taxAmount={taxAmount}
160
+ taxRate={taxRate}
161
+ size="sm"
162
+ t={t}
163
+ extraBetweenTaxAndTotal={extraBetweenTaxAndTotal}
164
+ totalLabel={totalSummaryLabel}
165
+ extraAfterTotal={extraAfterTotal}
166
+ lineAmountInputs={lineAmountInputs}
167
+ onLineAmountInputChange={onLineAmountInputChange}
168
+ onLineAmountInputBlur={onLineAmountInputBlur}
169
+ onLineAmountReset={onLineAmountReset}
170
+ extraBeforeSubtotal={extraBeforeSubtotal}
171
+ />
172
+ )}
163
173
  </div>
164
174
 
165
175
  {/* Contact info */}
@@ -14,7 +14,7 @@ const CURRENCY_NAMES: Record<Currency, string> = {
14
14
  AUD: 'AUD',
15
15
  };
16
16
 
17
- const DEFAULT_CURRENCY: Currency = 'CAD';
17
+ export const DEFAULT_CURRENCY: Currency = 'CAD';
18
18
 
19
19
  export function useCurrency() {
20
20
  const [currency, setCurrencyState] = useState<Currency>(DEFAULT_CURRENCY);
@@ -26,6 +26,7 @@ export interface MealDrinkAddOnSelectorProps {
26
26
  sectionLabel?: string;
27
27
  /** Minimum total quantity locked by original booking in change flow. */
28
28
  minimumTotal?: number;
29
+ suppressPrices?: boolean;
29
30
  }
30
31
 
31
32
  /**
@@ -163,6 +164,7 @@ export function MealDrinkAddOnSelector({
163
164
  `Of your ${total} lunch${total !== 1 ? 'es' : ''}, how many with Water vs Gatorade?`,
164
165
  sectionLabel = '🍽️ Order lunch?',
165
166
  minimumTotal = 0,
167
+ suppressPrices = false,
166
168
  }: MealDrinkAddOnSelectorProps) {
167
169
  const parsed = useMemo(
168
170
  () => (addOn.variantType === 'multi_quantity' && addOn.variants ? parseTwoStepVariants(addOn.variants) : null),
@@ -225,7 +227,7 @@ export function MealDrinkAddOnSelector({
225
227
  <div key={opt.id} className="flex items-center gap-3">
226
228
  <span className="font-medium text-stone-800">{opt.label}</span>
227
229
  <span className="text-sm text-stone-500 shrink-0">
228
- {formatCurrencyAmount(price, currency, locale)} ea
230
+ {suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale)} ea`}
229
231
  </span>
230
232
  <div className="flex items-center gap-1 shrink-0">
231
233
  <button
@@ -329,7 +331,7 @@ export function MealDrinkAddOnSelector({
329
331
 
330
332
  {totalStep1 > 0 && totalStep2 === totalStep1 && (
331
333
  <p className="text-sm font-medium text-stone-700">
332
- Total: {formatCurrencyAmount(price * totalStep1, currency, locale)}
334
+ {suppressPrices ? 'Total: —' : `Total: ${formatCurrencyAmount(price * totalStep1, currency, locale)}`}
333
335
  </p>
334
336
  )}
335
337
  </div>