@ticketboothapp/booking 1.2.123 → 1.2.125

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 (112) hide show
  1. package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
  2. package/package.json +1 -1
  3. package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +651 -6202
  5. package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
  6. package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
  7. package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
  8. package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
  9. package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
  10. package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
  11. package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
  12. package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
  13. package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
  14. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
  15. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
  16. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
  17. package/src/components/booking/CheckoutModal.tsx +68 -49
  18. package/src/components/booking/NewBookingFlow.tsx +485 -3505
  19. package/src/components/booking/PriceBreakdown.tsx +17 -3
  20. package/src/components/booking/PriceSummary.tsx +21 -3
  21. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
  22. package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
  23. package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
  24. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
  25. package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
  26. package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
  27. package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
  29. package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
  30. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
  31. package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
  32. package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
  33. package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
  34. package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
  35. package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
  36. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
  37. package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
  38. package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
  39. package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
  40. package/src/components/booking/admin-change-provider-payload.ts +101 -0
  41. package/src/components/booking/change-booking-checkout-builders.ts +185 -0
  42. package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
  43. package/src/components/booking/change-booking-flow-helpers.ts +706 -0
  44. package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
  45. package/src/components/booking/change-booking-quote-guards.ts +223 -0
  46. package/src/components/booking/change-booking-quote-state.ts +64 -0
  47. package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
  48. package/src/components/booking/private-shuttle-availability.ts +111 -0
  49. package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
  50. package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
  51. package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
  52. package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
  53. package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
  54. package/src/components/booking/standard-booking-availability.ts +305 -0
  55. package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
  56. package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
  57. package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
  58. package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
  59. package/src/components/booking/use-private-shuttle-availability.ts +602 -0
  60. package/src/components/booking/use-standard-booking-availability.ts +806 -0
  61. package/src/components/booking/useActiveProductOptions.ts +26 -0
  62. package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
  63. package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
  64. package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
  66. package/src/components/booking/useAdminChangeProductReset.ts +60 -0
  67. package/src/components/booking/useAdminChangeProducts.ts +102 -0
  68. package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
  69. package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
  70. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
  71. package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
  72. package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
  73. package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
  74. package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
  75. package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
  76. package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
  77. package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
  78. package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
  79. package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
  80. package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
  81. package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
  82. package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
  83. package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
  84. package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
  85. package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
  86. package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
  87. package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
  88. package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
  89. package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
  90. package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
  91. package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
  92. package/src/components/booking/useChangeBookingSelection.ts +362 -0
  93. package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
  94. package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
  95. package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
  96. package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
  97. package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
  98. package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
  99. package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
  100. package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
  101. package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
  102. package/src/components/booking/useStandardBookingPromoController.ts +258 -0
  103. package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
  104. package/src/data/products-config.json +1 -1
  105. package/src/lib/booking/change-booking-server-preview.ts +134 -20
  106. package/src/lib/booking/change-flow-pricing.ts +31 -11
  107. package/src/lib/booking/checkout-breakdown.ts +66 -1
  108. package/src/lib/booking/pricing-config.ts +10 -0
  109. package/src/lib/booking/source-metadata.ts +58 -0
  110. package/src/lib/booking-api.ts +155 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -0,0 +1,706 @@
1
+ import { parseISO } from 'date-fns';
2
+ import { formatInTimeZone } from 'date-fns-tz';
3
+ import type {
4
+ AddOn,
5
+ AdminFeAuthoritativeReceipt,
6
+ Availability,
7
+ PricingConfig,
8
+ PrecomputedPricesByCategory,
9
+ Product,
10
+ ReturnOption,
11
+ } from '../../lib/booking-api';
12
+ import {
13
+ getDisplayPriceFromBaseInDisplayCurrency,
14
+ computePriceBreakdown,
15
+ } from '../../lib/booking/pricing';
16
+ import { roundMoney } from '../../lib/booking/change-flow-pricing';
17
+ import type { PriceSummaryLine } from './PriceSummary';
18
+ import type { Currency } from './CurrencySwitcher';
19
+
20
+ const ZERO_PRICE_SUMMARY_LINE = 0.005;
21
+
22
+ export type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
23
+
24
+ /** Change-quote receipt lines may include a $0 promo/discount row; omit from sidebar summary only. */
25
+ export function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): PriceSummaryLine[] {
26
+ return lines.filter((line) => {
27
+ if (line.kind !== 'line') return true;
28
+ if (Math.abs(line.amount) >= ZERO_PRICE_SUMMARY_LINE) return true;
29
+ const t = String(line.type ?? '').toUpperCase();
30
+ if (t === 'PROMO_CODE' || t === 'DISCOUNT' || t === 'GIFT_CARD' || t === 'VOUCHER') return false;
31
+ const lab = line.label.toLowerCase();
32
+ if (lab.includes('promo')) return false;
33
+ if (lab.includes('voucher') || lab.includes('gift card')) return false;
34
+ return true;
35
+ });
36
+ }
37
+
38
+ export function isPromoDiscountGiftCardOrVoucherLine(line: {
39
+ type?: string | null;
40
+ label?: string | null;
41
+ amount?: number | null;
42
+ }): boolean {
43
+ const type = String(line.type ?? '').trim().toUpperCase();
44
+ if (type === 'PROMO_CODE' || type === 'DISCOUNT' || type === 'GIFT_CARD' || type === 'VOUCHER') {
45
+ return true;
46
+ }
47
+ const label = String(line.label ?? '').trim().toLowerCase();
48
+ return /promo|discount|voucher|gift\s*card/.test(label);
49
+ }
50
+
51
+ export function deriveOriginalReceiptPromoAdjustment(originalReceipt: {
52
+ promoAmount?: number | null;
53
+ promoLabel?: string | null;
54
+ lineItems?: Array<{ type?: string | null; label?: string | null; amount?: number | null }>;
55
+ } | null | undefined): {
56
+ amount: number;
57
+ label: string | null;
58
+ type: 'PROMO_CODE' | 'GIFT_CARD' | 'VOUCHER' | 'DISCOUNT';
59
+ } {
60
+ let amount = Math.max(0, Number(originalReceipt?.promoAmount) || 0);
61
+ let label = originalReceipt?.promoLabel?.trim() || null;
62
+ let type: 'PROMO_CODE' | 'GIFT_CARD' | 'VOUCHER' | 'DISCOUNT' = 'PROMO_CODE';
63
+
64
+ for (const line of originalReceipt?.lineItems ?? []) {
65
+ if (!isPromoDiscountGiftCardOrVoucherLine(line)) continue;
66
+ const lineAmount = Number(line.amount) || 0;
67
+ if (lineAmount < -0.005) {
68
+ amount = Math.max(amount, Math.abs(lineAmount));
69
+ if (!label) label = line.label?.trim() || null;
70
+ }
71
+ const lineType = String(line.type ?? '').trim().toUpperCase();
72
+ const lineLabel = String(line.label ?? '').trim().toLowerCase();
73
+ if (lineType === 'GIFT_CARD' || /gift\s*card/.test(lineLabel)) {
74
+ type = 'GIFT_CARD';
75
+ } else if (lineType === 'VOUCHER' || /voucher/.test(lineLabel)) {
76
+ type = 'VOUCHER';
77
+ } else if (lineType === 'DISCOUNT' || /discount/.test(lineLabel)) {
78
+ type = 'DISCOUNT';
79
+ }
80
+ }
81
+
82
+ return { amount: roundMoney(amount), label, type };
83
+ }
84
+
85
+ export function resolveTicketQtyFromQuantities(
86
+ category: string,
87
+ quantities: Record<string, number>,
88
+ ): number | undefined {
89
+ const c = category.trim();
90
+ if (Object.prototype.hasOwnProperty.call(quantities, c)) return quantities[c];
91
+ const up = c.toUpperCase();
92
+ for (const [key, val] of Object.entries(quantities)) {
93
+ if (key.toUpperCase() === up) return val;
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ export function mergePickerTicketQtyIntoPriceSummaryLines(
99
+ lines: PriceSummaryLine[],
100
+ quantities: Record<string, number>,
101
+ ): PriceSummaryLine[] {
102
+ return lines.map((line) => {
103
+ if (line.kind !== 'ticket') return line;
104
+ const uiQty = resolveTicketQtyFromQuantities(line.category, quantities);
105
+ const lineQty = Math.max(0, Number(line.qty) || 0);
106
+ const effectiveQty = uiQty != null && uiQty >= 0 ? Math.round(uiQty) : lineQty;
107
+ let itemTotal = roundMoney(line.itemTotal);
108
+ if (lineQty > 0 && effectiveQty !== lineQty) {
109
+ itemTotal = roundMoney((line.itemTotal * effectiveQty) / lineQty);
110
+ }
111
+ return { ...line, qty: Math.max(0, effectiveQty), itemTotal };
112
+ });
113
+ }
114
+
115
+ export function normalizeFeReceiptLineItemType(type: string | null | undefined): string | undefined {
116
+ const normalized = String(type ?? '').trim().toUpperCase();
117
+ if (!normalized) return undefined;
118
+ switch (normalized) {
119
+ case 'TICKET':
120
+ case 'ADULT':
121
+ case 'CHILD':
122
+ case 'INFANT':
123
+ case 'SENIOR':
124
+ case 'STUDENT':
125
+ return 'TICKET';
126
+ case 'RETURN':
127
+ case 'RETURN_OPTION':
128
+ case 'RETURNOPTION':
129
+ return 'RETURN_OPTION';
130
+ case 'CANCELLATION':
131
+ case 'CANCELLATION_UPGRADE':
132
+ case 'CANCELLATION_POLICY_UPGRADE':
133
+ return 'CANCELLATION_UPGRADE';
134
+ case 'PROMO':
135
+ case 'PROMO_CODE':
136
+ return 'PROMO_CODE';
137
+ case 'VOUCHER':
138
+ case 'DISCOUNT':
139
+ return 'DISCOUNT';
140
+ case 'GIFT_CARD':
141
+ return 'GIFT_CARD';
142
+ case 'DEAL':
143
+ return 'DEAL';
144
+ case 'TAX':
145
+ return 'TAX';
146
+ case 'ROUNDING':
147
+ return 'ROUNDING';
148
+ case 'BOOKING_CHANGE':
149
+ return 'BOOKING_CHANGE';
150
+ case 'FEE':
151
+ return 'FEE';
152
+ default:
153
+ return 'FEE';
154
+ }
155
+ }
156
+
157
+ /** Receipt TICKET rows sent on admin quote must follow picker quantities. */
158
+ export function mapSummaryLinesToFeReceiptLineItems(
159
+ lines: PriceSummaryLine[],
160
+ quantities: Record<string, number>,
161
+ ): NonNullable<AdminFeAuthoritativeReceipt['lineItems']> {
162
+ return lines.map((line) => {
163
+ if (line.kind === 'ticket') {
164
+ const uiQty = resolveTicketQtyFromQuantities(line.category, quantities);
165
+ const lineQty = Math.max(0, line.qty);
166
+ const effectiveQty =
167
+ uiQty != null && uiQty >= 0 ? Math.round(uiQty) : lineQty;
168
+ let amount = roundMoney(line.itemTotal);
169
+ if (lineQty > 0 && effectiveQty !== lineQty) {
170
+ amount = roundMoney((line.itemTotal * effectiveQty) / lineQty);
171
+ }
172
+ return {
173
+ label: line.category,
174
+ amount,
175
+ type: 'TICKET',
176
+ quantity: Math.max(0, effectiveQty),
177
+ };
178
+ }
179
+ return {
180
+ label: line.label,
181
+ amount: roundMoney(line.amount),
182
+ type: normalizeFeReceiptLineItemType(line.type),
183
+ quantity: line.quantity ?? undefined,
184
+ };
185
+ });
186
+ }
187
+
188
+ export function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
189
+ const labels: Record<string, string> = {
190
+ ADULT: 'adult',
191
+ CHILD: 'child',
192
+ INFANT: 'infant',
193
+ SENIOR: 'senior',
194
+ STUDENT: 'student',
195
+ };
196
+ const parts = lines
197
+ .filter((l) => l.qty > 0)
198
+ .map((l) => {
199
+ const label = labels[l.category] || l.category.toLowerCase();
200
+ return `${l.qty} ${label}${l.qty !== 1 ? 's' : ''}`;
201
+ });
202
+ return parts.length > 0 ? parts.join(', ') : '—';
203
+ }
204
+
205
+ export function normalizeTicketCategoryFromReceiptLabel(raw: string): string | null {
206
+ const normalized = raw.trim().toUpperCase();
207
+ if (!normalized) return null;
208
+ if (['ADULT', 'CHILD', 'INFANT', 'SENIOR', 'STUDENT'].includes(normalized)) return normalized;
209
+ const compact = normalized.replace(/[^A-Z]/g, ' ').replace(/\s+/g, ' ').trim();
210
+ if (/\bADULTS?\b/.test(compact)) return 'ADULT';
211
+ if (/\bCHILD(REN)?\b/.test(compact)) return 'CHILD';
212
+ if (/\bINFANTS?\b/.test(compact)) return 'INFANT';
213
+ if (/\bSENIORS?\b/.test(compact)) return 'SENIOR';
214
+ if (/\bSTUDENTS?\b/.test(compact)) return 'STUDENT';
215
+ return null;
216
+ }
217
+
218
+ export function extractTrailingQty(label: string): { baseLabel: string; qty: number } {
219
+ const trimmed = label.trim();
220
+ const m = trimmed.match(/^(.*?)(?:\s*[×x]\s*(\d+))$/);
221
+ if (!m) return { baseLabel: trimmed, qty: 1 };
222
+ const baseLabel = (m[1] || '').trim();
223
+ const qty = Math.max(1, Number(m[2]) || 1);
224
+ return { baseLabel, qty };
225
+ }
226
+
227
+ export function normalizeLineLabelForCompare(label: string): string {
228
+ return label
229
+ .toLowerCase()
230
+ .replace(/\([^)]*\)/g, '')
231
+ .replace(/[^a-z0-9]+/g, '')
232
+ .trim();
233
+ }
234
+
235
+ export function deriveAddOnSelectionsFromReceiptLines(
236
+ addOns: AddOn[],
237
+ lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
238
+ ): AddOnSelection[] {
239
+ const selections: AddOnSelection[] = [];
240
+ for (const line of lines) {
241
+ const type = (line.type || '').trim().toUpperCase();
242
+ if (type !== 'FEE') continue;
243
+ const amount = Number(line.amount ?? 0);
244
+ if (!Number.isFinite(amount) || amount <= 0) continue;
245
+ const rawLabel = (line.label || '').trim();
246
+ if (!rawLabel) continue;
247
+
248
+ const { baseLabel, qty } = extractTrailingQty(rawLabel);
249
+ let matched = false;
250
+
251
+ for (const addOn of addOns) {
252
+ if (matched) break;
253
+ const addOnName = addOn.name?.trim();
254
+ if (!addOnName) continue;
255
+
256
+ if (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') {
257
+ for (const variant of addOn.variants ?? []) {
258
+ const variantLabel = variant.label?.trim();
259
+ if (!variantLabel) continue;
260
+ if (baseLabel === `${addOnName} (${variantLabel})`) {
261
+ selections.push({ addOnId: addOn.addOnId, variantId: variant.id, quantity: qty });
262
+ matched = true;
263
+ break;
264
+ }
265
+ }
266
+ } else if (baseLabel === addOnName) {
267
+ selections.push({ addOnId: addOn.addOnId, quantity: qty });
268
+ matched = true;
269
+ }
270
+ }
271
+ }
272
+ return selections;
273
+ }
274
+
275
+ export function findMergedAvailabilityForSelection(
276
+ merged: Availability[],
277
+ selected: Availability | null
278
+ ): Availability | undefined {
279
+ if (!selected) return undefined;
280
+ const optId = getAvailabilityOptionId(selected) || undefined;
281
+ const dt = selected.dateTime;
282
+ const availId = selected.availabilityId?.trim();
283
+ if (availId && optId) {
284
+ const exact = merged.find(
285
+ (a) =>
286
+ a.availabilityId === availId &&
287
+ getAvailabilityOptionId(a) === optId
288
+ );
289
+ if (exact) return exact;
290
+ }
291
+ return merged.find((a) => a.dateTime === dt && getAvailabilityOptionId(a) === optId);
292
+ }
293
+
294
+ export function getAvailabilityOptionId(availability: Availability, fallbackOptionId?: string): string {
295
+ const productOptionId = availability.productOptionId?.trim();
296
+ if (productOptionId) return productOptionId;
297
+ const productId = availability.productId?.trim();
298
+ if (productId?.startsWith('po_')) return productId;
299
+ return fallbackOptionId?.trim() ?? '';
300
+ }
301
+
302
+ export function getAvailabilityCacheKey(availability: Availability): string {
303
+ return `${availability.dateTime}-${getAvailabilityOptionId(availability)}`;
304
+ }
305
+
306
+ export type PricingRate = {
307
+ category: string;
308
+ rateId: string;
309
+ available: number | undefined;
310
+ price: number;
311
+ priceCAD: number;
312
+ baseInDisplayCurrency: number;
313
+ appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>;
314
+ };
315
+
316
+ /** Ticket rates for one availability row; mirrors main cart pricing so baseline slots match BE. */
317
+ export function buildPricingFromAvailability(
318
+ selectedAvailability: Availability | null,
319
+ activeOptions: Product['options'],
320
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
321
+ currency: Currency,
322
+ pricingConfig: PricingConfig | null,
323
+ hasFees: boolean,
324
+ isSimplifiedPricingView: boolean,
325
+ ) {
326
+ if (!selectedAvailability || !pricingConfig) return [];
327
+ const optionId = getAvailabilityOptionId(selectedAvailability);
328
+ const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
329
+ const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
330
+ const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
331
+ getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
332
+ const getBaseInDisplayCurrency = (category: string) => {
333
+ const fromPrecomputed = precomputed?.[category]?.[currency];
334
+ if (fromPrecomputed != null) return fromPrecomputed;
335
+ return 0;
336
+ };
337
+ const buildRate = (
338
+ category: string,
339
+ backendPriceCAD: number,
340
+ backendInDisplayCurrency: number,
341
+ baseInDisplayCurrency: number,
342
+ appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
343
+ ) => {
344
+ const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
345
+ const hasOptionScopedPrice = baseInDisplayCurrency > 0;
346
+ const priceCADForBreakdown =
347
+ hasOptionScopedPrice && currency === 'CAD' ? baseInDisplayCurrency : backendPriceCAD;
348
+ const displayCurrencyPriceForFallback = hasOptionScopedPrice
349
+ ? baseInDisplayCurrency
350
+ : backendInDisplayCurrency;
351
+ const isPublicMode = isSimplifiedPricingView;
352
+ const breakdown = computePriceBreakdown(
353
+ pricingConfig,
354
+ currency,
355
+ priceCADForBreakdown,
356
+ basePriceCAD,
357
+ hasFees,
358
+ appliedAdjustments,
359
+ undefined,
360
+ baseInDisplayCurrency,
361
+ isPublicMode,
362
+ );
363
+ const price = breakdown?.finalPrice ?? rateToDisplayPrice(displayCurrencyPriceForFallback);
364
+ return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: priceCADForBreakdown };
365
+ };
366
+ return (
367
+ selectedAvailability.rates?.map((rate) => {
368
+ const backendPriceCAD = rate.price ?? 0;
369
+ const backendInDisplayCurrency =
370
+ rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
371
+ const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
372
+ const built = buildRate(
373
+ rate.category,
374
+ backendPriceCAD,
375
+ backendInDisplayCurrency,
376
+ baseInDisplayCurrency,
377
+ rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
378
+ );
379
+ return {
380
+ category: rate.category,
381
+ rateId: rate.rateId || rate.category,
382
+ available: rate.available,
383
+ price: built.price,
384
+ priceCAD: built.priceCAD,
385
+ baseInDisplayCurrency: built.baseInDisplayCurrency,
386
+ appliedAdjustments: built.appliedAdjustments,
387
+ };
388
+ }) ||
389
+ selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
390
+ const priceCADFromApi = p.price / 100;
391
+ const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
392
+ const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
393
+ const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
394
+ return {
395
+ category: p.category,
396
+ rateId: p.category,
397
+ available: selectedAvailability.vacancies,
398
+ price: built.price,
399
+ priceCAD: built.priceCAD,
400
+ baseInDisplayCurrency: built.baseInDisplayCurrency,
401
+ appliedAdjustments: built.appliedAdjustments,
402
+ };
403
+ }) ||
404
+ []
405
+ );
406
+ }
407
+
408
+ export function findMergedReturnVacancies(
409
+ outbound: Availability | undefined,
410
+ selectedReturn: ReturnOption | null | undefined
411
+ ): number | null {
412
+ if (!selectedReturn?.returnAvailabilityId || !outbound?.returnOptions?.length) return null;
413
+ const ro = outbound.returnOptions.find(
414
+ (r) => r.returnAvailabilityId === selectedReturn.returnAvailabilityId
415
+ );
416
+ return ro ? ro.vacancies : null;
417
+ }
418
+
419
+ export function normalizeAddOnSelections(selections: AddOnSelection[]): AddOnSelection[] {
420
+ const qtyByKey = new Map<string, number>();
421
+ for (const sel of selections) {
422
+ const addOnId = sel.addOnId?.trim();
423
+ if (!addOnId) continue;
424
+ const variantId = sel.variantId?.trim() || '';
425
+ const key = `${addOnId}::${variantId}`;
426
+ qtyByKey.set(key, (qtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
427
+ }
428
+ return Array.from(qtyByKey.entries()).map(([key, qty]) => {
429
+ const [addOnId, variantIdRaw] = key.split('::');
430
+ const variantId = variantIdRaw?.trim() || undefined;
431
+ return { addOnId, variantId, quantity: qty };
432
+ });
433
+ }
434
+
435
+ export function parseAvailabilityDateTime(value: string): Date {
436
+ // If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
437
+ const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
438
+ return parseISO(hasExplicitOffset ? value : `${value}Z`);
439
+ }
440
+
441
+ export function deriveDefaultQuantitiesFromAvailabilities(
442
+ availabilities: Availability[]
443
+ ): Record<string, number> | null {
444
+ const firstWithRates = availabilities.find((avail) => avail.rates && avail.rates.length > 0);
445
+ if (!firstWithRates?.rates?.length) return null;
446
+
447
+ const initialQuantities: Record<string, number> = {};
448
+ firstWithRates.rates.forEach((rate) => {
449
+ initialQuantities[rate.category] = rate.category === 'ADULT' ? 1 : 0;
450
+ });
451
+
452
+ return Object.keys(initialQuantities).length > 0 ? initialQuantities : null;
453
+ }
454
+
455
+ /** Clock time in the company IANA zone, never the browser local zone. */
456
+ export function getMinutesFromMidnightInTimezone(isoDateTime: string, ianaTimeZone: string): number {
457
+ const dt = parseAvailabilityDateTime(isoDateTime);
458
+ const h = parseInt(formatInTimeZone(dt, ianaTimeZone, 'H'), 10);
459
+ const m = parseInt(formatInTimeZone(dt, ianaTimeZone, 'm'), 10);
460
+ return h * 60 + m;
461
+ }
462
+
463
+ export function minCircularMinutesDiff(a: number, b: number): number {
464
+ const d = Math.abs(a - b);
465
+ return Math.min(d, 24 * 60 - d);
466
+ }
467
+
468
+ export function pickOutboundMatchingPreviousSelection(
469
+ timesForSelectedDate: Availability[],
470
+ anchor: { productOptionId: string | null; minutesFromMidnight: number },
471
+ companyTimezone: string,
472
+ optionsMap: Map<string, { mostPopular?: boolean }>,
473
+ includeSoldOut = false
474
+ ): Availability | null {
475
+ const selectable = timesForSelectedDate.filter((a) => (a.vacancies ?? 0) > 0 || includeSoldOut);
476
+ if (selectable.length === 0) return null;
477
+
478
+ const withOption =
479
+ anchor.productOptionId != null
480
+ ? selectable.filter((a) => a.productOptionId === anchor.productOptionId)
481
+ : [];
482
+ const pool = withOption.length > 0 ? withOption : selectable;
483
+
484
+ const sorted = [...pool].sort((a, b) => {
485
+ const da = minCircularMinutesDiff(
486
+ getMinutesFromMidnightInTimezone(a.dateTime, companyTimezone),
487
+ anchor.minutesFromMidnight
488
+ );
489
+ const db = minCircularMinutesDiff(
490
+ getMinutesFromMidnightInTimezone(b.dateTime, companyTimezone),
491
+ anchor.minutesFromMidnight
492
+ );
493
+ if (Math.abs(da - db) > 0.5) return da - db;
494
+ const aPop = optionsMap.get(a.productOptionId ?? '')?.mostPopular ? 1 : 0;
495
+ const bPop = optionsMap.get(b.productOptionId ?? '')?.mostPopular ? 1 : 0;
496
+ if (aPop !== bPop) return bPop - aPop;
497
+ return parseAvailabilityDateTime(a.dateTime).getTime() - parseAvailabilityDateTime(b.dateTime).getTime();
498
+ });
499
+ return sorted[0] ?? null;
500
+ }
501
+
502
+ export function pickReturnMatchingPreviousSelection(
503
+ sortedReturnOptions: ReturnOption[],
504
+ anchor: { returnLocation: string; minutesFromMidnight: number },
505
+ companyTimezone: string,
506
+ includeSoldOut = false
507
+ ): ReturnOption | null {
508
+ const eligible = sortedReturnOptions.filter((o) => (o.vacancies ?? 0) > 0 || includeSoldOut);
509
+ if (eligible.length === 0) return null;
510
+
511
+ const sameLoc = eligible.filter((o) => o.returnLocation === anchor.returnLocation);
512
+ const pool = sameLoc.length > 0 ? sameLoc : eligible;
513
+
514
+ const sorted = [...pool].sort((a, b) => {
515
+ const da = minCircularMinutesDiff(
516
+ getMinutesFromMidnightInTimezone(a.dateTime, companyTimezone),
517
+ anchor.minutesFromMidnight
518
+ );
519
+ const db = minCircularMinutesDiff(
520
+ getMinutesFromMidnightInTimezone(b.dateTime, companyTimezone),
521
+ anchor.minutesFromMidnight
522
+ );
523
+ if (Math.abs(da - db) > 0.5) return da - db;
524
+ return parseAvailabilityDateTime(a.dateTime).getTime() - parseAvailabilityDateTime(b.dateTime).getTime();
525
+ });
526
+ return sorted[0] ?? null;
527
+ }
528
+
529
+ export function scoreTicketSubtotalForOption(
530
+ optionId: string | undefined,
531
+ bookingItems: Array<{ category: string; count: number }> | null | undefined,
532
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
533
+ currency: Currency
534
+ ): number | null {
535
+ if (!optionId || !bookingItems?.length || !precomputedPricesByOption?.[optionId]) return null;
536
+ const precomputed = precomputedPricesByOption[optionId];
537
+ let sum = 0;
538
+ for (const { category, count } of bookingItems) {
539
+ if (count <= 0) continue;
540
+ const key = category?.trim();
541
+ if (!key) continue;
542
+ const unit =
543
+ precomputed[key]?.[currency] ??
544
+ precomputed[key.toUpperCase()]?.[currency] ??
545
+ precomputed[key.toLowerCase()]?.[currency];
546
+ if (unit == null) return null;
547
+ sum += unit * count;
548
+ }
549
+ return sum;
550
+ }
551
+
552
+ export function disambiguateAvailabilityCandidates(
553
+ candidates: Availability[],
554
+ initialProductOptionId: string | undefined,
555
+ bookingItems: Array<{ category: string; count: number }> | null | undefined,
556
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
557
+ currency: Currency,
558
+ originalSubtotalBeforeTax: number | undefined
559
+ ): Availability | null {
560
+ const withVacancyFirst = [...candidates].sort((a, b) => (b.vacancies > 0 ? 1 : 0) - (a.vacancies > 0 ? 1 : 0));
561
+ const pool = withVacancyFirst.length > 0 ? withVacancyFirst : candidates;
562
+ if (pool.length === 0) return null;
563
+ if (pool.length === 1) return pool[0];
564
+
565
+ if (initialProductOptionId) {
566
+ const matched = pool.find((a) => a.productOptionId === initialProductOptionId);
567
+ if (matched) return matched;
568
+ }
569
+
570
+ if (originalSubtotalBeforeTax != null && precomputedPricesByOption && bookingItems?.length) {
571
+ let best: Availability | null = null;
572
+ let bestDiff = Infinity;
573
+ for (const c of pool) {
574
+ const score = scoreTicketSubtotalForOption(c.productOptionId, bookingItems, precomputedPricesByOption, currency);
575
+ if (score == null) continue;
576
+ const diff = Math.abs(score - originalSubtotalBeforeTax);
577
+ if (
578
+ diff < bestDiff - 0.005 ||
579
+ (diff <= bestDiff + 0.005 && (best == null || (c.vacancies > 0 && best.vacancies <= 0)))
580
+ ) {
581
+ bestDiff = diff;
582
+ best = c;
583
+ }
584
+ }
585
+ if (best) return best;
586
+ }
587
+
588
+ return pool.find((a) => a.vacancies > 0) ?? pool[0];
589
+ }
590
+
591
+ export function resolveInitialAvailabilityFromBooking(
592
+ timesForSelectedDate: Availability[],
593
+ target: Date,
594
+ companyTimezone: string,
595
+ initialAvailabilityId: string | null | undefined,
596
+ initialProductOptionId: string | null | undefined,
597
+ bookingItems: Array<{ category: string; count: number }> | null | undefined,
598
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
599
+ currency: Currency,
600
+ originalSubtotalBeforeTax: number | undefined
601
+ ): { selection: Availability | null; defer: boolean } {
602
+ const availId = initialAvailabilityId?.trim() || null;
603
+ const optId = initialProductOptionId?.trim() || null;
604
+
605
+ if (availId) {
606
+ const byAvail = timesForSelectedDate.find((a) => a.availabilityId === availId);
607
+ if (byAvail) return { selection: byAvail, defer: false };
608
+ }
609
+
610
+ const targetMs = target.getTime();
611
+ const sameInstant = timesForSelectedDate.filter(
612
+ (a) => parseAvailabilityDateTime(a.dateTime).getTime() === targetMs
613
+ );
614
+
615
+ if (sameInstant.length > 0) {
616
+ if (sameInstant.length === 1) {
617
+ return { selection: sameInstant[0], defer: false };
618
+ }
619
+ const ambiguous =
620
+ !optId &&
621
+ !availId &&
622
+ new Set(sameInstant.map((s) => s.productOptionId).filter(Boolean)).size > 1;
623
+ if (ambiguous && !precomputedPricesByOption) {
624
+ return { selection: null, defer: true };
625
+ }
626
+ const picked = disambiguateAvailabilityCandidates(
627
+ sameInstant,
628
+ optId ?? undefined,
629
+ bookingItems,
630
+ precomputedPricesByOption,
631
+ currency,
632
+ originalSubtotalBeforeTax
633
+ );
634
+ return { selection: picked, defer: false };
635
+ }
636
+
637
+ const localKey = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd HH:mm');
638
+ const sameLocalWall = timesForSelectedDate.filter((a) => {
639
+ const key = formatInTimeZone(parseAvailabilityDateTime(a.dateTime), companyTimezone, 'yyyy-MM-dd HH:mm');
640
+ return key === localKey;
641
+ });
642
+
643
+ if (sameLocalWall.length > 0) {
644
+ if (sameLocalWall.length === 1) {
645
+ return { selection: sameLocalWall[0], defer: false };
646
+ }
647
+ const ambiguous =
648
+ !optId &&
649
+ !availId &&
650
+ new Set(sameLocalWall.map((s) => s.productOptionId).filter(Boolean)).size > 1;
651
+ if (ambiguous && !precomputedPricesByOption) {
652
+ return { selection: null, defer: true };
653
+ }
654
+ const picked = disambiguateAvailabilityCandidates(
655
+ sameLocalWall,
656
+ optId ?? undefined,
657
+ bookingItems,
658
+ precomputedPricesByOption,
659
+ currency,
660
+ originalSubtotalBeforeTax
661
+ );
662
+ return { selection: picked, defer: false };
663
+ }
664
+
665
+ if (optId) {
666
+ const byOption = timesForSelectedDate.filter((a) => a.productOptionId === optId);
667
+ if (byOption.length > 0) {
668
+ const timeMatch = byOption.find((a) => parseAvailabilityDateTime(a.dateTime).getTime() === targetMs);
669
+ if (timeMatch) return { selection: timeMatch, defer: false };
670
+ const localMatch = byOption.find(
671
+ (a) =>
672
+ formatInTimeZone(parseAvailabilityDateTime(a.dateTime), companyTimezone, 'yyyy-MM-dd HH:mm') === localKey
673
+ );
674
+ if (localMatch) return { selection: localMatch, defer: false };
675
+ const picked = disambiguateAvailabilityCandidates(
676
+ byOption,
677
+ optId,
678
+ bookingItems,
679
+ precomputedPricesByOption,
680
+ currency,
681
+ originalSubtotalBeforeTax
682
+ );
683
+ return { selection: picked, defer: false };
684
+ }
685
+ }
686
+
687
+ const fallback = timesForSelectedDate.find((a) => a.vacancies > 0) ?? null;
688
+ return { selection: fallback, defer: false };
689
+ }
690
+
691
+ export function isPrivateShuttleProduct(product: Pick<Product, 'productType'> | null | undefined): boolean {
692
+ return product?.productType === 'PRIVATE_SHUTTLE';
693
+ }
694
+
695
+ export function getAdminChangeEligibleProducts(products: Product[], currentProduct: Product): Product[] {
696
+ if (isPrivateShuttleProduct(currentProduct)) return [currentProduct];
697
+ const activeStandardProducts = products.filter(
698
+ (p) =>
699
+ p.status === 'ACTIVE' &&
700
+ !isPrivateShuttleProduct(p) &&
701
+ p.options?.some((o) => o.status === 'ACTIVE'),
702
+ );
703
+ return activeStandardProducts.some((p) => p.productId === currentProduct.productId)
704
+ ? activeStandardProducts
705
+ : [currentProduct, ...activeStandardProducts];
706
+ }