@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.
- package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +651 -6202
- package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
- package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
- package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
- package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
- package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
- package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
- package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
- package/src/components/booking/CheckoutModal.tsx +68 -49
- package/src/components/booking/NewBookingFlow.tsx +485 -3505
- package/src/components/booking/PriceBreakdown.tsx +17 -3
- package/src/components/booking/PriceSummary.tsx +21 -3
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
- package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
- package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
- package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
- package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
- package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
- package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
- package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
- package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
- package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
- package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
- package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
- package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
- package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
- package/src/components/booking/admin-change-provider-payload.ts +101 -0
- package/src/components/booking/change-booking-checkout-builders.ts +185 -0
- package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
- package/src/components/booking/change-booking-flow-helpers.ts +706 -0
- package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
- package/src/components/booking/change-booking-quote-guards.ts +223 -0
- package/src/components/booking/change-booking-quote-state.ts +64 -0
- package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
- package/src/components/booking/private-shuttle-availability.ts +111 -0
- package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
- package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
- package/src/components/booking/standard-booking-availability.ts +305 -0
- package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
- package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
- package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
- package/src/components/booking/use-private-shuttle-availability.ts +602 -0
- package/src/components/booking/use-standard-booking-availability.ts +806 -0
- package/src/components/booking/useActiveProductOptions.ts +26 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
- package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
- package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
- package/src/components/booking/useAdminChangeProductReset.ts +60 -0
- package/src/components/booking/useAdminChangeProducts.ts +102 -0
- package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
- package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
- package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
- package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
- package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
- package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
- package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
- package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
- package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
- package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
- package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
- package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
- package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
- package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
- package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
- package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
- package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
- package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
- package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
- package/src/components/booking/useChangeBookingSelection.ts +362 -0
- package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
- package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
- package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
- package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
- package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
- package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
- package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
- package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
- package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
- package/src/components/booking/useStandardBookingPromoController.ts +258 -0
- package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
- package/src/data/products-config.json +1 -1
- package/src/lib/booking/change-booking-server-preview.ts +134 -20
- package/src/lib/booking/change-flow-pricing.ts +31 -11
- package/src/lib/booking/checkout-breakdown.ts +66 -1
- package/src/lib/booking/pricing-config.ts +10 -0
- package/src/lib/booking/source-metadata.ts +58 -0
- package/src/lib/booking-api.ts +155 -3
- package/src/lib/pricing-v2-shadow.ts +272 -0
- 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
|
+
}
|