@ticketboothapp/booking 1.2.61 → 1.2.63

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.
@@ -1176,9 +1176,10 @@ export function PrivateShuttleBookingFlow({
1176
1176
  const cancelPendingReservation = useCallback(() => {
1177
1177
  if (paymentSubmitInFlightRef.current) return;
1178
1178
  const pending = pendingReservationRef.current;
1179
- if (!pending) return;
1180
- pendingReservationRef.current = null;
1181
- cancelReservation(pending.reservationReference).catch(() => {});
1179
+ if (pending) {
1180
+ pendingReservationRef.current = null;
1181
+ cancelReservation(pending.reservationReference).catch(() => {});
1182
+ }
1182
1183
  setShowCheckoutModal(false);
1183
1184
  setCheckoutModalData(null);
1184
1185
  setCheckoutClientSecret('');
@@ -1187,9 +1188,10 @@ export function PrivateShuttleBookingFlow({
1187
1188
  const cancelPendingReservationBestEffort = useCallback(() => {
1188
1189
  if (paymentSubmitInFlightRef.current) return;
1189
1190
  const pending = pendingReservationRef.current;
1190
- if (!pending) return;
1191
- pendingReservationRef.current = null;
1192
- cancelReservationBestEffort(pending.reservationReference);
1191
+ if (pending) {
1192
+ pendingReservationRef.current = null;
1193
+ cancelReservationBestEffort(pending.reservationReference);
1194
+ }
1193
1195
  setShowCheckoutModal(false);
1194
1196
  setCheckoutModalData(null);
1195
1197
  setCheckoutClientSecret('');
@@ -20,6 +20,8 @@ interface PromoCodeInputProps {
20
20
  onApply: () => void;
21
21
  onRemove: () => void;
22
22
  locked?: boolean;
23
+ /** Hide the −$ discount suffix (pending server-backed totals). */
24
+ hideDiscountAmount?: boolean;
23
25
  }
24
26
 
25
27
  export function PromoCodeInput({
@@ -35,6 +37,7 @@ export function PromoCodeInput({
35
37
  onApply,
36
38
  onRemove,
37
39
  locked = false,
40
+ hideDiscountAmount = false,
38
41
  }: PromoCodeInputProps) {
39
42
  const showSuffix = !!(appliedPromoCode || promoCodeValidating || promoCodeError);
40
43
  const suffixPadding =
@@ -97,7 +100,7 @@ export function PromoCodeInput({
97
100
  </div>
98
101
  )}
99
102
  </div>
100
- {promoDiscountAmount > 0 && (
103
+ {promoDiscountAmount > 0 && !hideDiscountAmount && (
101
104
  <span className={styles.promoDiscount}>
102
105
  -{formatCurrencyAmount(promoDiscountAmount, currency, locale as 'en' | 'fr')}
103
106
  </span>
@@ -20,6 +20,8 @@ interface ReturnTimeSelectorProps {
20
20
  t: TranslationFn;
21
21
  onReturnSelect: (option: ReturnOption) => void;
22
22
  getStaySummary: (returnDateTime: Date) => string | null;
23
+ /** Hide per-person return add-on amounts until server-backed pricing. */
24
+ suppressPerPersonPrices?: boolean;
23
25
  }
24
26
 
25
27
  export function ReturnTimeSelector({
@@ -33,6 +35,7 @@ export function ReturnTimeSelector({
33
35
  t,
34
36
  onReturnSelect,
35
37
  getStaySummary,
38
+ suppressPerPersonPrices = false,
36
39
  }: ReturnTimeSelectorProps) {
37
40
  const sortedReturnOptions = [...returnOptions].sort((a, b) => {
38
41
  const timeA = parseISO(a.dateTime).getTime();
@@ -129,11 +132,15 @@ export function ReturnTimeSelector({
129
132
  )}
130
133
  </div>
131
134
  <div className={`${styles.price} ${priceClass}`}>
132
- {priceAdjustmentPerPerson === 0
133
- ? 'Included'
134
- : priceAdjustmentPerPerson > 0
135
- ? `+${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale as 'en' | 'fr')} per person`
136
- : `${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale as 'en' | 'fr')} per person`}
135
+ {suppressPerPersonPrices ? (
136
+ ''
137
+ ) : priceAdjustmentPerPerson === 0 ? (
138
+ 'Included'
139
+ ) : priceAdjustmentPerPerson > 0 ? (
140
+ `+${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale as 'en' | 'fr')} per person`
141
+ ) : (
142
+ `${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale as 'en' | 'fr')} per person`
143
+ )}
137
144
  </div>
138
145
  </div>
139
146
  </button>
@@ -46,6 +46,8 @@ interface TicketSelectorProps {
46
46
  minimumQuantities?: Record<string, number>;
47
47
  /** Optional per-category unit floor (change flow). Keys are uppercased categories (e.g. ADULT). */
48
48
  ticketUnitFloorByCategory?: Map<string, number>;
49
+ /** Hide unit/strike prices (customer change flow until server-backed pricing is shown). */
50
+ suppressUnitPrices?: boolean;
49
51
  }
50
52
 
51
53
  export function TicketSelector({
@@ -67,6 +69,7 @@ export function TicketSelector({
67
69
  onQuantityChange,
68
70
  minimumQuantities,
69
71
  ticketUnitFloorByCategory,
72
+ suppressUnitPrices = false,
70
73
  }: TicketSelectorProps) {
71
74
  /** Party size vs tighter of pickup/return vacancies (parent passes min of both legs). */
72
75
  const isOverbookedTickets = totalQuantity > selectedVacancies;
@@ -147,7 +150,9 @@ export function TicketSelector({
147
150
  )}
148
151
  </p>
149
152
  <p className={styles.price}>
150
- {hasDiscount ? (
153
+ {suppressUnitPrices ? (
154
+ <span className="text-stone-400">—</span>
155
+ ) : hasDiscount ? (
151
156
  <>
152
157
  <span className={styles.priceStrikethrough}>
153
158
  {formatCurrencyAmount(displayBasePrice, currency, locale as 'en' | 'fr')}
@@ -0,0 +1,141 @@
1
+ import type { RefObject } from 'react';
2
+ import type { Product } from '../../lib/booking-api';
3
+ import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
4
+ import type { Currency } from './CurrencySwitcher';
5
+ import type { BookingFlowUiOptions } from './booking-flow-ui';
6
+
7
+ /** Live selection snapshot for change-booking compare UI (parent dialog). */
8
+ export interface ChangeFlowSelectionPreview {
9
+ tourName: string;
10
+ dateTime: string | null;
11
+ ticketsLine: string;
12
+ itinerarySteps: Array<{ time: string | null; label: string }>;
13
+ /** Whether the selected date/time differs from the original booking. */
14
+ dateChanged: boolean;
15
+ /** Whether the selected tickets/product option differs from the original booking. */
16
+ ticketsChanged: boolean;
17
+ /** When false, UI can hide the “new booking” column (selection matches original). */
18
+ hasChangesFromInitial: boolean;
19
+ /** New selection grand total for parent dialog; null until server quote supplies authoritative pricing. */
20
+ selectionTotal: number | null;
21
+ selectionCurrency: Currency;
22
+ }
23
+
24
+ /**
25
+ * Props shared by {@link NewBookingFlow} and {@link ChangeBookingFlow}
26
+ * (product, embed shell, attribution — not change-specific callbacks or receipt).
27
+ */
28
+ export interface BookingFlowBaseProps {
29
+ product: Product;
30
+ /** Product slug (e.g. from URL) - used to look up collage images and video */
31
+ productId?: string;
32
+ onBack: () => void;
33
+ currency: Currency;
34
+ /** Scroll container ref (e.g. from BookingDialog) - scroll happens inside this element, not window */
35
+ contentRef?: RefObject<HTMLDivElement | null>;
36
+ /**
37
+ * Optional callback called when reservation is successfully created (before checkout redirect)
38
+ * If provided, indicates we're in an embedded context (e.g., provider dashboard)
39
+ */
40
+ onSuccess?: (data: { reservationReference: string; bookingReference?: string }) => void;
41
+ /** When true, shows tour description only (no calendar/checkout). Used for time-gated launch. */
42
+ isPartialLaunch?: boolean;
43
+ /**
44
+ * When true, treat the window as the scroll container (full-page layout),
45
+ * instead of relying on an internal scrollable div. Used on partner booking pages.
46
+ */
47
+ useWindowScroll?: boolean;
48
+ /** Optional promo code to auto-apply when the flow loads (e.g., partner pages). */
49
+ autoAppliedPromoCode?: string;
50
+ /** Optional extra discount percent to show on calendar date badges. */
51
+ calendarDiscountPercent?: number;
52
+ /** Optional pickup location IDs to prioritize/highlight in selector (e.g. partner-preferred). */
53
+ highlightedPickupLocationIds?: string[];
54
+ onPricePreviewChange?: (preview: {
55
+ subtotal: number;
56
+ tax: number;
57
+ total: number;
58
+ currency: Currency;
59
+ } | null) => void;
60
+ initialValues?: {
61
+ bookingReference?: string | null;
62
+ dateTime?: string | null;
63
+ /** Inventory slot id from booking (when API persists it); strongest match for change flow. */
64
+ availabilityId?: string | null;
65
+ /** Parent product id when option id is omitted or differs from availability rows (GYG/ticketbooth shape). */
66
+ productId?: string | null;
67
+ productOptionId?: string | null;
68
+ pickupLocationId?: string | null;
69
+ /** Original booked return slot (round-trip), when API provides it. */
70
+ returnAvailabilityId?: string | null;
71
+ /** Fallback when only return datetime is on the booking payload. */
72
+ returnDateTime?: string | null;
73
+ /** Persisted return floor per person from booking API (must match ticketbooth `return_unit_floor_per_person`). */
74
+ returnUnitFloorPerPerson?: number | null;
75
+ bookingItems?: Array<{ category: string; count: number }> | null;
76
+ addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
77
+ customer?: {
78
+ firstName?: string | null;
79
+ lastName?: string | null;
80
+ email?: string | null;
81
+ } | null;
82
+ promoCode?: string | null;
83
+ /** Booked policy id (change flow); must match server quote which uses booking.cancellationPolicyId. */
84
+ cancellationPolicyId?: string | null;
85
+ /**
86
+ * Change flow: currency the booking was sold in. The change UI and pricing must not switch to another currency
87
+ * (parent `currency` prop is ignored when this or `originalReceipt.currency` is set).
88
+ */
89
+ currency?: Currency | null;
90
+ };
91
+ /**
92
+ * When true, suppress the built-in itinerary box/placeholder.
93
+ * Useful when a parent dialog already renders its own itinerary summary UI.
94
+ */
95
+ hideItineraryBox?: boolean;
96
+ /** Partner / embed-only tweaks; omit for default website behavior. */
97
+ flowUi?: BookingFlowUiOptions;
98
+ /** Explicit reserve/checkout source metadata (browser URL layer + optional portal merge at call site). */
99
+ bookingSourceAttribution: Partial<BookingSourceMetadata>;
100
+ /** Dedicated partner portal app (`booking.*`): persist reserve `source` as PARTNER_PORTAL. */
101
+ partnerPortalBooking?: boolean;
102
+ /** When set (e.g. partner portal), get-availabilities requests this pricing profile from the API. */
103
+ availabilityPricingProfileId?: string | null;
104
+ /** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
105
+ availabilityCancellationPolicyProfileId?: string | null;
106
+ }
107
+
108
+ /** Standard (new) reservation flow — no change-booking receipt or callbacks. */
109
+ export interface NewBookingFlowProps extends BookingFlowBaseProps {}
110
+
111
+ /** Change booking — adds receipt snapshot, compare preview, and dashboard selection preview hooks. */
112
+ export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
113
+ /** Called when date/tickets/itinerary selection updates (for side-by-side compare in dialog). */
114
+ onChangeFlowSelectionPreview?: (preview: ChangeFlowSelectionPreview | null) => void;
115
+ originalReceipt?: {
116
+ subtotal: number;
117
+ tax: number;
118
+ total: number;
119
+ currency: Currency;
120
+ promoAmount?: number;
121
+ promoLabel?: string | null;
122
+ lineItems?: Array<{
123
+ type?: string;
124
+ label?: string;
125
+ amount?: number;
126
+ quantity?: number;
127
+ }>;
128
+ } | null;
129
+ /**
130
+ * When true, {@link BookingFlow} renders {@link AdminChangeBookingFlow} (placeholder split from customer flow).
131
+ */
132
+ useAdminChangeBookingFlow?: boolean;
133
+ }
134
+
135
+ /**
136
+ * Router props for {@link BookingFlow}: discriminated union so `mode: 'change'` implies
137
+ * {@link ChangeBookingFlowProps}; otherwise {@link NewBookingFlowProps}.
138
+ */
139
+ export type BookingFlowProps =
140
+ | (NewBookingFlowProps & { mode?: 'standard' })
141
+ | (ChangeBookingFlowProps & { mode: 'change' });
package/src/index.ts CHANGED
@@ -13,7 +13,16 @@ export {
13
13
 
14
14
  /** Canonical Via Via booking UI — same modules as `@/components/booking/*` on the site. */
15
15
  export { BookingFlow } from './components/booking/BookingFlow';
16
- export type { ChangeFlowSelectionPreview } from './components/booking/BookingFlow';
16
+ export { NewBookingFlow } from './components/booking/NewBookingFlow';
17
+ export { ChangeBookingFlow } from './components/booking/ChangeBookingFlow';
18
+ export { AdminChangeBookingFlow } from './components/booking/AdminChangeBookingFlow';
19
+ export type {
20
+ BookingFlowBaseProps,
21
+ BookingFlowProps,
22
+ ChangeBookingFlowProps,
23
+ ChangeFlowSelectionPreview,
24
+ NewBookingFlowProps,
25
+ } from './components/booking/booking-flow-types';
17
26
  export { PrivateShuttleBookingFlow } from './components/booking/PrivateShuttleBookingFlow';
18
27
  export type { BookingFlowUiOptions } from './components/booking/booking-flow-ui';
19
28
  export type {
@@ -0,0 +1,331 @@
1
+ import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
2
+ import type {
3
+ ChangeBookingQuotePricingDriftDetail,
4
+ ChangeBookingQuotePricingDriftLine,
5
+ ChangeBookingQuoteResponse,
6
+ ChangeBookingQuoteTicketLineTrace,
7
+ ChangeBookingQuoteTicketPricingTrace,
8
+ } from '../booking-api';
9
+ import { roundMoney } from './change-flow-pricing';
10
+
11
+ export interface ChangeBookingPricingDriftRow {
12
+ key: string;
13
+ label: string;
14
+ /** Amount from the in-app cart / client proposal (major units). */
15
+ clientAmount: number | null;
16
+ /** Amount from the server price check (major units). */
17
+ serverAmount: number | null;
18
+ /** client − server when both present. */
19
+ delta: number | null;
20
+ /** Ticket qty when row resolved to a ticket category (for unit math in UI). */
21
+ clientQty?: number | null;
22
+ serverQty?: number | null;
23
+ }
24
+
25
+ function normalizeDriftLabelKey(raw: string): string {
26
+ return raw
27
+ .toLowerCase()
28
+ .replace(/\([^)]*\)/g, '')
29
+ .replace(/[^a-z0-9]+/g, '')
30
+ .trim();
31
+ }
32
+
33
+ function driftKey(line: PriceSummaryLine): string {
34
+ if (line.kind === 'ticket') {
35
+ return `ticket:${String(line.category || '').trim().toUpperCase()}`;
36
+ }
37
+ const type = (line.type ?? 'LINE').toUpperCase();
38
+ const lab = normalizeDriftLabelKey(line.label);
39
+ return `line:${type}:${lab}`;
40
+ }
41
+
42
+ const TICKET_CATEGORY_WORDS = new Set(['ADULT', 'CHILD', 'INFANT', 'SENIOR', 'STUDENT']);
43
+
44
+ /**
45
+ * Aligns receipt-style server rows (e.g. label “ADULT” as a generic line) with {@link PriceSummaryLine} ticket rows
46
+ * (`ticket:ADULT`) so drift merge fills both “In app” and “Server” columns.
47
+ */
48
+ export function canonicalDriftKey(line: PriceSummaryLine): string {
49
+ if (line.kind === 'ticket') {
50
+ return `ticket:${String(line.category || '').trim().toUpperCase()}`;
51
+ }
52
+ const type = (line.type ?? 'LINE').toUpperCase();
53
+ const labRaw = line.label.trim();
54
+
55
+ const lettersOnly = labRaw.toUpperCase().replace(/[^A-Z]/g, '');
56
+ if (lettersOnly.length >= 3 && TICKET_CATEGORY_WORDS.has(lettersOnly) && labRaw.length <= 32) {
57
+ return `ticket:${lettersOnly}`;
58
+ }
59
+
60
+ const multi = labRaw.match(/^(\d+)\s*[×x]\s*(.+)$/i);
61
+ if (multi) {
62
+ const cat = multi[2]
63
+ .trim()
64
+ .toUpperCase()
65
+ .replace(/[^A-Z]/g, '');
66
+ if (TICKET_CATEGORY_WORDS.has(cat)) {
67
+ return `ticket:${cat}`;
68
+ }
69
+ }
70
+
71
+ const lab = normalizeDriftLabelKey(line.label);
72
+ return `line:${type}:${lab}`;
73
+ }
74
+
75
+ function driftLabel(line: PriceSummaryLine): string {
76
+ if (line.kind === 'ticket') {
77
+ return `${line.category} × ${line.qty}`;
78
+ }
79
+ return line.label.trim() || line.type || 'Line';
80
+ }
81
+
82
+ function driftAmount(line: PriceSummaryLine): number {
83
+ return line.kind === 'ticket' ? line.itemTotal : line.amount;
84
+ }
85
+
86
+ /** Sum line amounts (major units) for debug totals when API sends receipt-style rows only. */
87
+ export function sumPriceSummaryLinesMajorUnits(lines: PriceSummaryLine[]): number {
88
+ let s = 0;
89
+ for (const line of lines) {
90
+ s += driftAmount(line);
91
+ }
92
+ return roundMoney(s);
93
+ }
94
+
95
+ function num(v: unknown): number | undefined {
96
+ if (v == null || v === '') return undefined;
97
+ const n = Number(v);
98
+ return Number.isFinite(n) ? n : undefined;
99
+ }
100
+
101
+ /**
102
+ * Normalize `pricingDriftDetail` from `POST …/change/quote`, including snake_case keys used by temporary BE payloads.
103
+ */
104
+ export function normalizePricingDriftDetailFromQuote(
105
+ quote: ChangeBookingQuoteResponse,
106
+ ): ChangeBookingQuotePricingDriftDetail | undefined {
107
+ const q = quote as unknown as Record<string, unknown>;
108
+ const nested =
109
+ (quote.pricingDriftDetail as Record<string, unknown> | undefined) ??
110
+ (q.pricing_drift_detail as Record<string, unknown> | undefined);
111
+ if (!nested || typeof nested !== 'object') return undefined;
112
+
113
+ const n = nested as Record<string, unknown>;
114
+ const rawLines = (a: unknown): ChangeBookingQuotePricingDriftDetail['serverLineItems'] => {
115
+ if (!Array.isArray(a)) return undefined;
116
+ return a as ChangeBookingQuotePricingDriftDetail['serverLineItems'];
117
+ };
118
+
119
+ const comp = n.lineComparisons ?? n.line_comparisons;
120
+ const lineComparisons =
121
+ Array.isArray(comp) && comp.length > 0
122
+ ? (comp as unknown[]).map((x) => {
123
+ if (!x || typeof x !== 'object') return { label: '' };
124
+ const o = x as Record<string, unknown>;
125
+ return {
126
+ label: String(o.label ?? ''),
127
+ clientAmountMajorUnits: num(o.clientAmountMajorUnits ?? o.client_amount_major_units) ?? null,
128
+ serverAmountMajorUnits: num(o.serverAmountMajorUnits ?? o.server_amount_major_units) ?? null,
129
+ };
130
+ })
131
+ : undefined;
132
+
133
+ const out: ChangeBookingQuotePricingDriftDetail = {
134
+ clientTotalMajorUnits: num(n.clientTotalMajorUnits ?? n.client_total_major_units),
135
+ serverTotalMajorUnits: num(n.serverTotalMajorUnits ?? n.server_total_major_units),
136
+ deltaMajorUnits: num(n.deltaMajorUnits ?? n.delta_major_units),
137
+ lineComparisons,
138
+ serverLineItems: rawLines(n.serverLineItems ?? n.server_line_items),
139
+ clientLineItems: rawLines(n.clientLineItems ?? n.client_line_items),
140
+ };
141
+
142
+ if (
143
+ out.clientTotalMajorUnits == null &&
144
+ out.serverTotalMajorUnits == null &&
145
+ out.deltaMajorUnits == null &&
146
+ !out.lineComparisons?.length &&
147
+ !out.serverLineItems?.length &&
148
+ !out.clientLineItems?.length
149
+ ) {
150
+ return undefined;
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Reads optional `ticketPricingTrace` from change-quote JSON (camelCase or snake_case).
157
+ */
158
+ export function normalizeTicketPricingTraceFromQuote(
159
+ quote: ChangeBookingQuoteResponse,
160
+ ): ChangeBookingQuoteTicketPricingTrace | undefined {
161
+ const q = quote as unknown as Record<string, unknown>;
162
+ const raw = q.ticketPricingTrace ?? q.ticket_pricing_trace;
163
+ if (!raw || typeof raw !== 'object') return undefined;
164
+ const t = raw as Record<string, unknown>;
165
+ const lockedRaw = t.lockedTicketUnitPriceByCategory ?? t.locked_ticket_unit_price_by_category;
166
+ const locked: Record<string, number> = {};
167
+ if (lockedRaw && typeof lockedRaw === 'object' && !Array.isArray(lockedRaw)) {
168
+ for (const [k, v] of Object.entries(lockedRaw as Record<string, unknown>)) {
169
+ const n = num(v);
170
+ if (n != null) locked[k] = n;
171
+ }
172
+ }
173
+ const linesRaw = t.ticketLines ?? t.ticket_lines;
174
+ const ticketLines: ChangeBookingQuoteTicketLineTrace[] = [];
175
+ if (Array.isArray(linesRaw)) {
176
+ for (const row of linesRaw) {
177
+ if (!row || typeof row !== 'object') continue;
178
+ const o = row as Record<string, unknown>;
179
+ const cat = String(o.category ?? '');
180
+ const rule = String(o.rule ?? '');
181
+ const lu = num(o.liveUnitMajorUnits ?? o.live_unit_major_units);
182
+ const lk = num(o.lockedUnitMajorUnits ?? o.locked_unit_major_units);
183
+ const pq = Number(o.protectedQty ?? o.protected_qty ?? 0);
184
+ const iq = Number(o.incrementalQty ?? o.incremental_qty ?? 0);
185
+ const catTot = num(o.catalogLineTotalMajorUnits ?? o.catalog_line_total_major_units);
186
+ const amt = num(o.ticketLineAmountMajorUnits ?? o.ticket_line_amount_major_units);
187
+ if (!cat || lu == null || catTot == null || amt == null) continue;
188
+ ticketLines.push({
189
+ category: cat,
190
+ rule,
191
+ liveUnitMajorUnits: lu,
192
+ lockedUnitMajorUnits: lk ?? undefined,
193
+ protectedQty: Number.isFinite(pq) ? pq : 0,
194
+ incrementalQty: Number.isFinite(iq) ? iq : 0,
195
+ catalogLineTotalMajorUnits: catTot,
196
+ ticketLineAmountMajorUnits: amt,
197
+ });
198
+ }
199
+ }
200
+ const bp = String(t.bookingParentProductId ?? t.booking_parent_product_id ?? '');
201
+ const rp = String(t.requestedParentProductId ?? t.requested_parent_product_id ?? '');
202
+ const bo = String(t.bookingOptionId ?? t.booking_option_id ?? '');
203
+ const ro = String(t.requestedOptionId ?? t.requested_option_id ?? '');
204
+ if (!bp || !rp || !bo || !ro) return undefined;
205
+ return {
206
+ bookingParentProductId: bp,
207
+ requestedParentProductId: rp,
208
+ bookingOptionId: bo,
209
+ requestedOptionId: ro,
210
+ sameParentProduct: Boolean(t.sameParentProduct ?? t.same_parent_product),
211
+ unchangedItinerary: Boolean(t.unchangedItinerary ?? t.unchanged_itinerary),
212
+ lockedTicketUnitPriceByCategory: Object.keys(locked).length > 0 ? locked : undefined,
213
+ ticketLines,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Merge client-built {@link PriceSummaryLine} rows with server receipt lines for a side-by-side drift table.
219
+ */
220
+ export function mergePriceSummaryLinesForDrift(
221
+ clientLines: PriceSummaryLine[],
222
+ serverLines: PriceSummaryLine[],
223
+ ): ChangeBookingPricingDriftRow[] {
224
+ const mapClient = new Map<string, { label: string; amount: number; qty?: number }>();
225
+ const mapServer = new Map<string, { label: string; amount: number; qty?: number }>();
226
+
227
+ for (const line of clientLines) {
228
+ const k = canonicalDriftKey(line);
229
+ const qty = line.kind === 'ticket' ? line.qty : undefined;
230
+ mapClient.set(k, { label: driftLabel(line), amount: driftAmount(line), qty });
231
+ }
232
+ for (const line of serverLines) {
233
+ const k = canonicalDriftKey(line);
234
+ const qty = line.kind === 'ticket' ? line.qty : undefined;
235
+ mapServer.set(k, { label: driftLabel(line), amount: driftAmount(line), qty });
236
+ }
237
+
238
+ const keys = [...new Set([...mapClient.keys(), ...mapServer.keys()])];
239
+ keys.sort((a, b) => a.localeCompare(b));
240
+
241
+ const rows: ChangeBookingPricingDriftRow[] = [];
242
+ for (const key of keys) {
243
+ const c = mapClient.get(key);
244
+ const s = mapServer.get(key);
245
+ const clientAmount = c?.amount ?? null;
246
+ const serverAmount = s?.amount ?? null;
247
+ const label = c?.label ?? s?.label ?? key;
248
+ let delta: number | null = null;
249
+ if (clientAmount != null && serverAmount != null) {
250
+ delta = roundMoney(clientAmount - serverAmount);
251
+ }
252
+ const clientQty = c?.qty ?? null;
253
+ const serverQty = s?.qty ?? null;
254
+ rows.push({
255
+ key,
256
+ label,
257
+ clientAmount,
258
+ serverAmount,
259
+ delta,
260
+ clientQty: clientQty ?? undefined,
261
+ serverQty: serverQty ?? undefined,
262
+ });
263
+ }
264
+ return rows;
265
+ }
266
+
267
+ function findMergedRowForApiLabel(
268
+ mergedRows: ChangeBookingPricingDriftRow[],
269
+ apiLabel: string,
270
+ ): ChangeBookingPricingDriftRow | undefined {
271
+ const trimmed = apiLabel.trim();
272
+ if (!trimmed) return undefined;
273
+ const upper = trimmed.toUpperCase();
274
+ const norm = normalizeDriftLabelKey(trimmed);
275
+
276
+ let hit = mergedRows.find((r) => r.label === trimmed);
277
+ if (hit) return hit;
278
+
279
+ hit = mergedRows.find((r) => normalizeDriftLabelKey(r.label) === norm);
280
+ if (hit) return hit;
281
+
282
+ /** Return rows: BE label is often English "Return option (N people)" while the app may use i18n. */
283
+ if (/\breturn\b/i.test(trimmed) || norm.includes('return')) {
284
+ hit = mergedRows.find(
285
+ (r) =>
286
+ r.key.startsWith('line:RETURN') ||
287
+ r.key.toLowerCase().includes(':return') ||
288
+ /\breturn\b/i.test(r.label),
289
+ );
290
+ if (hit) return hit;
291
+ }
292
+
293
+ for (const cat of TICKET_CATEGORY_WORDS) {
294
+ if (upper === cat || upper.startsWith(`${cat} `) || new RegExp(`\\b${cat}\\b`, 'i').test(trimmed)) {
295
+ hit = mergedRows.find((r) => r.key === `ticket:${cat}`);
296
+ if (hit) return hit;
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * API `lineComparisons` often omit client line amounts; fill from {@link mergePriceSummaryLinesForDrift} so “In app” is not blank.
304
+ */
305
+ export function enrichLineComparisonsWithMergedRows(
306
+ apiLines: ChangeBookingQuotePricingDriftLine[],
307
+ mergedRows: ChangeBookingPricingDriftRow[],
308
+ ): ChangeBookingPricingDriftRow[] {
309
+ return apiLines.map((line, i) => {
310
+ const base = findMergedRowForApiLabel(mergedRows, line.label);
311
+ const clientAmount =
312
+ line.clientAmountMajorUnits != null && Number.isFinite(Number(line.clientAmountMajorUnits))
313
+ ? Number(line.clientAmountMajorUnits)
314
+ : base?.clientAmount ?? null;
315
+ const serverAmount =
316
+ line.serverAmountMajorUnits != null && Number.isFinite(Number(line.serverAmountMajorUnits))
317
+ ? Number(line.serverAmountMajorUnits)
318
+ : base?.serverAmount ?? null;
319
+ const delta =
320
+ clientAmount != null && serverAmount != null ? roundMoney(clientAmount - serverAmount) : null;
321
+ return {
322
+ key: `api-line:${i}`,
323
+ label: line.label,
324
+ clientAmount,
325
+ serverAmount,
326
+ delta,
327
+ clientQty: base?.clientQty ?? undefined,
328
+ serverQty: base?.serverQty ?? undefined,
329
+ };
330
+ });
331
+ }