@ticketboothapp/booking 1.2.48 → 1.2.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/BookingDetails.ts +18 -0
- package/src/components/booking/AddOnsSection.tsx +2 -2
- package/src/components/booking/AdminPaymentChoiceModal.tsx +1 -1
- package/src/components/booking/BookingDialog.tsx +1 -1
- package/src/components/booking/BookingFlow.tsx +16 -16
- package/src/components/booking/BookingFlowCollage.tsx +3 -2
- package/src/components/booking/BookingFlowPlaceholder.tsx +1 -1
- package/src/components/booking/BookingFlowPreview.tsx +1 -1
- package/src/components/booking/BookingProductGrid.tsx +4 -3
- package/src/components/booking/Calendar.tsx +5 -5
- package/src/components/booking/CancellationPolicySelector.tsx +2 -2
- package/src/components/booking/ChangeBookingDialog.tsx +6 -5
- package/src/components/booking/CheckoutForm.tsx +1 -1
- package/src/components/booking/CheckoutModal.tsx +3 -3
- package/src/components/booking/DapTourDescription.tsx +3 -3
- package/src/components/booking/DependentAddOnBookingDialog.tsx +7 -7
- package/src/components/booking/DependentAddOnPaymentForm.tsx +1 -1
- package/src/components/booking/ItineraryBox.tsx +6 -6
- package/src/components/booking/ItineraryBuilder.tsx +1 -1
- package/src/components/booking/MealDrinkAddOnSelector.tsx +3 -3
- package/src/components/booking/PickupLocationSelector.tsx +8 -8
- package/src/components/booking/PickupTimeSelector.tsx +2 -2
- package/src/components/booking/PriceBreakdown.tsx +4 -4
- package/src/components/booking/PriceSummary.tsx +3 -3
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +11 -11
- package/src/components/booking/PromoCodeInput.tsx +1 -1
- package/src/components/booking/ReturnTimeSelector.tsx +2 -2
- package/src/components/booking/TicketSelector.tsx +1 -1
- package/src/components/booking/TourDescription.tsx +1 -1
- package/src/constants/images.ts +556 -0
- package/src/constants/products.ts +33 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/CompanyContext.tsx +70 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +1 -1
- package/src/hooks/useIsBookingLaunchLive.ts +1 -1
- package/src/lib/booking/booking-source.ts +51 -0
- package/src/lib/booking/checkout-breakdown.ts +69 -0
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/booking/i18n/config.ts +21 -0
- package/src/lib/booking/i18n/index.tsx +144 -0
- package/src/lib/booking/i18n/messages/en.json +236 -0
- package/src/lib/booking/i18n/messages/fr.json +236 -0
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/booking/itinerary-labels.ts +70 -0
- package/src/lib/booking/location-calculations.ts +43 -0
- package/src/lib/booking/location-utils.ts +165 -0
- package/src/lib/booking/map-utils.ts +153 -0
- package/src/lib/booking/marker-icons.ts +113 -0
- package/src/lib/booking/normalize-booking-product-id.ts +21 -0
- package/src/lib/booking/pickup-location-types.ts +25 -0
- package/src/lib/booking/places-api.ts +154 -0
- package/src/lib/booking/pricing.ts +466 -0
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +226 -0
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking/utils.ts +9 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/booking-constants.ts +23 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +81 -0
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +102 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/providers/booking-dialog-provider.tsx +3 -3
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/types.d.ts +11 -0
- package/ticketboothapp-booking-1.2.48.tgz +0 -0
- package/ticketboothapp-booking-1.2.49.tgz +0 -0
- package/tsconfig.json +3 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TicketBooth dependent add-on (DAP) APIs — booking-reference-first flow.
|
|
3
|
+
*
|
|
4
|
+
* Availability response may include primary booking itinerary (no last name typed by user):
|
|
5
|
+
* `data.itineraryDisplay` or `data.primaryItineraryDisplay`
|
|
6
|
+
* and `data.primaryCustomerLastName` when stored on the booking.
|
|
7
|
+
* Same step shape as public booking `itineraryDisplay` (stepType, time, place, label).
|
|
8
|
+
*
|
|
9
|
+
* Itinerary-aware slots (client + recommended server behavior):
|
|
10
|
+
* For each `arrive` → next `depart` pair, treat [arriveTime, departTime] as an on-site
|
|
11
|
+
* window. Only offer slots fully contained in at least one window for the trip day.
|
|
12
|
+
* This module filters the GET response when an itinerary is present (see
|
|
13
|
+
* `filterSlotsByPrimaryItineraryWindows`). TicketBooth should apply the same rules when
|
|
14
|
+
* building offerings so capacity and pricing stay authoritative.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ENV } from './env';
|
|
18
|
+
import {
|
|
19
|
+
filterDapSlotsToPrimaryItineraryWindows,
|
|
20
|
+
normalizeDapItinerarySteps,
|
|
21
|
+
type DapItineraryStep,
|
|
22
|
+
} from './dap-itinerary-preview';
|
|
23
|
+
|
|
24
|
+
export type { DapItineraryStep };
|
|
25
|
+
|
|
26
|
+
const API_BASE = ENV.API_URL;
|
|
27
|
+
|
|
28
|
+
function getAuthHeaders(): Record<string, string> {
|
|
29
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
30
|
+
if (ENV.BASIC_AUTH) {
|
|
31
|
+
headers['Authorization'] = `Basic ${ENV.BASIC_AUTH}`;
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function parseJsonSafely(res: Response): Promise<unknown> {
|
|
37
|
+
try {
|
|
38
|
+
return await res.json();
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApiErrorPayload {
|
|
45
|
+
errorCode?: string;
|
|
46
|
+
errorMessage?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
|
|
51
|
+
return typeof value === 'object' && value !== null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function messageFromPayload(payload: unknown, fallback: string): string {
|
|
55
|
+
if (isApiErrorPayload(payload)) {
|
|
56
|
+
return payload.errorMessage || payload.error || fallback;
|
|
57
|
+
}
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickStr(obj: Record<string, unknown>, ...keys: string[]): string | undefined {
|
|
62
|
+
for (const k of keys) {
|
|
63
|
+
const v = obj[k];
|
|
64
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pickNum(obj: Record<string, unknown>, ...keys: string[]): number | undefined {
|
|
70
|
+
for (const k of keys) {
|
|
71
|
+
const v = obj[k];
|
|
72
|
+
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
|
73
|
+
if (typeof v === 'string' && v.trim()) {
|
|
74
|
+
const n = Number(v);
|
|
75
|
+
if (!Number.isNaN(n)) return n;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Positive whole days, capped for sanity (DAP cancellation window from TicketBooth). */
|
|
82
|
+
function pickPositiveDayCount(obj: Record<string, unknown>, ...keys: string[]): number | undefined {
|
|
83
|
+
const n = pickNum(obj, ...keys);
|
|
84
|
+
if (n === undefined || !Number.isFinite(n)) return undefined;
|
|
85
|
+
const floor = Math.floor(n);
|
|
86
|
+
if (floor < 1) return undefined;
|
|
87
|
+
return Math.min(floor, 365);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractDapCancellationDaysBeforeSession(
|
|
91
|
+
inner: Record<string, unknown> | null | undefined
|
|
92
|
+
): number | undefined {
|
|
93
|
+
if (!inner) return undefined;
|
|
94
|
+
const top = pickPositiveDayCount(
|
|
95
|
+
inner,
|
|
96
|
+
'cancellationDaysBeforeSession',
|
|
97
|
+
'cancellation_days_before_session'
|
|
98
|
+
);
|
|
99
|
+
if (top !== undefined) return top;
|
|
100
|
+
const prod =
|
|
101
|
+
inner.dependentAddOnProduct ?? inner.dependent_add_on_product ?? inner.product;
|
|
102
|
+
if (prod && typeof prod === 'object') {
|
|
103
|
+
return pickPositiveDayCount(
|
|
104
|
+
prod as Record<string, unknown>,
|
|
105
|
+
'cancellationDaysBeforeSession',
|
|
106
|
+
'cancellation_days_before_session'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Normalize TicketBooth create-payment-intent JSON (nested `data`, snake_case). */
|
|
113
|
+
function coercePaymentIntentResult(payload: unknown): CreateDependentAddOnPaymentIntentResult {
|
|
114
|
+
const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
|
|
115
|
+
const inner =
|
|
116
|
+
root.data && typeof root.data === 'object'
|
|
117
|
+
? (root.data as Record<string, unknown>)
|
|
118
|
+
: root;
|
|
119
|
+
return {
|
|
120
|
+
clientSecret: pickStr(inner, 'clientSecret', 'client_secret'),
|
|
121
|
+
totalAmount: pickNum(inner, 'totalAmount', 'total_amount'),
|
|
122
|
+
currency: pickStr(inner, 'currency'),
|
|
123
|
+
subtotalAmount: pickNum(inner, 'subtotalAmount', 'subtotal_amount'),
|
|
124
|
+
taxAmount: pickNum(inner, 'taxAmount', 'tax_amount'),
|
|
125
|
+
customerLastName: pickStr(inner, 'customerLastName', 'customer_last_name'),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type DependentAddOnCheckoutQuestionType = 'TEXT' | 'CHECKBOX';
|
|
130
|
+
|
|
131
|
+
export interface DependentAddOnCheckoutQuestion {
|
|
132
|
+
id: string;
|
|
133
|
+
type: DependentAddOnCheckoutQuestionType;
|
|
134
|
+
label: string;
|
|
135
|
+
required?: boolean;
|
|
136
|
+
maxLength?: number | null;
|
|
137
|
+
placeholder?: string | null;
|
|
138
|
+
helpText?: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface DependentAddOnSlot {
|
|
142
|
+
offeringId: string;
|
|
143
|
+
resourceId: string;
|
|
144
|
+
resourceName?: string;
|
|
145
|
+
slotStart: string;
|
|
146
|
+
slotEnd: string;
|
|
147
|
+
capacityTotal?: number;
|
|
148
|
+
capacityRemaining?: number;
|
|
149
|
+
price?: number;
|
|
150
|
+
currency?: string;
|
|
151
|
+
dependentAddOnProductOptionId?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface GetDependentAddOnAvailabilityParams {
|
|
155
|
+
companyId: string;
|
|
156
|
+
primaryBookingReference: string;
|
|
157
|
+
lastName: string;
|
|
158
|
+
dependentAddOnProductId: string;
|
|
159
|
+
dependentAddOnProductOptionId?: string;
|
|
160
|
+
/** YYYY-MM-DD — limits slots to one local day when supported */
|
|
161
|
+
date?: string;
|
|
162
|
+
/**
|
|
163
|
+
* When true (default), drop slots not fully inside an arrive→depart on-site window
|
|
164
|
+
* derived from `primaryItineraryDisplay` in the same response (or after normalize).
|
|
165
|
+
*/
|
|
166
|
+
filterSlotsByPrimaryItineraryWindows?: boolean;
|
|
167
|
+
/** Pass from AbortController to cancel in-flight work (e.g. route change, Strict Mode remount). */
|
|
168
|
+
signal?: AbortSignal;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type DependentAddOnBookingUpsellProductRow = {
|
|
172
|
+
dependentAddOnProductId: string;
|
|
173
|
+
dependentAddOnProductOptionId?: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export interface GetDependentAddOnBookingUpsellEligibilityParams {
|
|
177
|
+
companyId: string;
|
|
178
|
+
primaryBookingReference: string;
|
|
179
|
+
lastName: string;
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Single TicketBooth read for manage-booking photo upsell: ACTIVE DAP products that have at least one
|
|
185
|
+
* bookable slot for this primary booking (same last-name gate as availability). Replaces N parallel
|
|
186
|
+
* `getDependentAddOnAvailability` probes.
|
|
187
|
+
*/
|
|
188
|
+
export async function getDependentAddOnBookingUpsellEligibility(
|
|
189
|
+
params: GetDependentAddOnBookingUpsellEligibilityParams
|
|
190
|
+
): Promise<{ productsWithSlots: DependentAddOnBookingUpsellProductRow[] }> {
|
|
191
|
+
const endpoint = '/1/dependent-add-ons/booking-upsell-eligibility';
|
|
192
|
+
const q = new URLSearchParams({
|
|
193
|
+
companyId: params.companyId,
|
|
194
|
+
primaryBookingReference: params.primaryBookingReference.trim(),
|
|
195
|
+
lastName: params.lastName.trim(),
|
|
196
|
+
});
|
|
197
|
+
const url = `${API_BASE}${endpoint}?${q}`;
|
|
198
|
+
if (params.signal?.aborted) {
|
|
199
|
+
const aborted = new Error('Aborted');
|
|
200
|
+
aborted.name = 'AbortError';
|
|
201
|
+
throw aborted;
|
|
202
|
+
}
|
|
203
|
+
let res: Response;
|
|
204
|
+
try {
|
|
205
|
+
res = await fetch(url, {
|
|
206
|
+
method: 'GET',
|
|
207
|
+
headers: getAuthHeaders(),
|
|
208
|
+
signal: params.signal,
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (err instanceof Error && err.name === 'AbortError') throw err;
|
|
212
|
+
if (
|
|
213
|
+
typeof DOMException !== 'undefined' &&
|
|
214
|
+
err instanceof DOMException &&
|
|
215
|
+
err.name === 'AbortError'
|
|
216
|
+
) {
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
220
|
+
throw new Error(`Unable to load add-on upsell eligibility. ${msg}`);
|
|
221
|
+
}
|
|
222
|
+
const payload = await parseJsonSafely(res);
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
messageFromPayload(payload, `Could not load upsell eligibility (HTTP ${res.status})`)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const data = payload as { data?: Record<string, unknown> } | null;
|
|
229
|
+
const inner = data?.data;
|
|
230
|
+
const rawList = inner?.productsWithSlots ?? inner?.products_with_slots;
|
|
231
|
+
const rows: DependentAddOnBookingUpsellProductRow[] = [];
|
|
232
|
+
if (Array.isArray(rawList)) {
|
|
233
|
+
for (const item of rawList) {
|
|
234
|
+
if (!item || typeof item !== 'object') continue;
|
|
235
|
+
const o = item as Record<string, unknown>;
|
|
236
|
+
const id =
|
|
237
|
+
pickStr(o, 'dependentAddOnProductId', 'dependent_add_on_product_id') ?? '';
|
|
238
|
+
if (!id) continue;
|
|
239
|
+
const opt = pickStr(
|
|
240
|
+
o,
|
|
241
|
+
'dependentAddOnProductOptionId',
|
|
242
|
+
'dependent_add_on_product_option_id'
|
|
243
|
+
);
|
|
244
|
+
rows.push({
|
|
245
|
+
dependentAddOnProductId: id,
|
|
246
|
+
...(opt ? { dependentAddOnProductOptionId: opt } : {}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { productsWithSlots: rows };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type DependentAddOnAvailabilityResult = {
|
|
254
|
+
slots: DependentAddOnSlot[];
|
|
255
|
+
/** Present when API includes itinerary for this reference (preview without last name). */
|
|
256
|
+
primaryItineraryDisplay: DapItineraryStep[];
|
|
257
|
+
/** Primary booking last name from TicketBooth (for manage-booking after DAP payment). */
|
|
258
|
+
primaryCustomerLastName?: string;
|
|
259
|
+
/** When TicketBooth sends it on this DAP product / availability payload. */
|
|
260
|
+
cancellationDaysBeforeSession?: number;
|
|
261
|
+
/** Server-defined checkout questionnaire for this DAP (empty if none). */
|
|
262
|
+
checkoutQuestions: DependentAddOnCheckoutQuestion[];
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export async function getDependentAddOnAvailability(
|
|
266
|
+
params: GetDependentAddOnAvailabilityParams
|
|
267
|
+
): Promise<DependentAddOnAvailabilityResult> {
|
|
268
|
+
const endpoint = '/1/dependent-add-ons/availability';
|
|
269
|
+
const q = new URLSearchParams({
|
|
270
|
+
companyId: params.companyId,
|
|
271
|
+
primaryBookingReference: params.primaryBookingReference.trim(),
|
|
272
|
+
lastName: params.lastName.trim(),
|
|
273
|
+
dependentAddOnProductId: params.dependentAddOnProductId,
|
|
274
|
+
});
|
|
275
|
+
if (params.dependentAddOnProductOptionId?.trim()) {
|
|
276
|
+
q.set('dependentAddOnProductOptionId', params.dependentAddOnProductOptionId.trim());
|
|
277
|
+
}
|
|
278
|
+
if (params.date?.trim()) {
|
|
279
|
+
q.set('date', params.date.trim());
|
|
280
|
+
}
|
|
281
|
+
const url = `${API_BASE}${endpoint}?${q}`;
|
|
282
|
+
if (params.signal?.aborted) {
|
|
283
|
+
const aborted = new Error('Aborted');
|
|
284
|
+
aborted.name = 'AbortError';
|
|
285
|
+
throw aborted;
|
|
286
|
+
}
|
|
287
|
+
let res: Response;
|
|
288
|
+
try {
|
|
289
|
+
res = await fetch(url, {
|
|
290
|
+
method: 'GET',
|
|
291
|
+
headers: getAuthHeaders(),
|
|
292
|
+
signal: params.signal,
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (err instanceof Error && err.name === 'AbortError') throw err;
|
|
296
|
+
if (
|
|
297
|
+
typeof DOMException !== 'undefined' &&
|
|
298
|
+
err instanceof DOMException &&
|
|
299
|
+
err.name === 'AbortError'
|
|
300
|
+
) {
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
304
|
+
throw new Error(`Unable to load add-on times. ${msg}`);
|
|
305
|
+
}
|
|
306
|
+
const payload = await parseJsonSafely(res);
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
throw new Error(messageFromPayload(payload, `Could not load times (HTTP ${res.status})`));
|
|
309
|
+
}
|
|
310
|
+
const data = payload as { data?: Record<string, unknown> } | null;
|
|
311
|
+
const inner = data?.data;
|
|
312
|
+
const cancellationDaysBeforeSession = extractDapCancellationDaysBeforeSession(inner);
|
|
313
|
+
const slots = inner?.slots;
|
|
314
|
+
const itineraryRaw =
|
|
315
|
+
inner?.itineraryDisplay ?? inner?.primaryItineraryDisplay ?? null;
|
|
316
|
+
const primaryCustomerLastName = inner
|
|
317
|
+
? pickStr(
|
|
318
|
+
inner,
|
|
319
|
+
'primaryCustomerLastName',
|
|
320
|
+
'primary_customer_last_name'
|
|
321
|
+
)
|
|
322
|
+
: undefined;
|
|
323
|
+
const primaryItineraryDisplay = normalizeDapItinerarySteps(itineraryRaw);
|
|
324
|
+
const rawSlots = Array.isArray(slots) ? slots : [];
|
|
325
|
+
const shouldFilterWindows = params.filterSlotsByPrimaryItineraryWindows !== false;
|
|
326
|
+
const filteredSlots =
|
|
327
|
+
shouldFilterWindows && primaryItineraryDisplay.length > 0
|
|
328
|
+
? filterDapSlotsToPrimaryItineraryWindows(rawSlots, primaryItineraryDisplay)
|
|
329
|
+
: rawSlots;
|
|
330
|
+
|
|
331
|
+
const checkoutQuestions = normalizeCheckoutQuestions(
|
|
332
|
+
inner?.checkoutQuestions ?? inner?.checkout_questions
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
slots: filteredSlots,
|
|
337
|
+
primaryItineraryDisplay,
|
|
338
|
+
primaryCustomerLastName,
|
|
339
|
+
checkoutQuestions,
|
|
340
|
+
...(cancellationDaysBeforeSession !== undefined
|
|
341
|
+
? { cancellationDaysBeforeSession }
|
|
342
|
+
: {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizeCheckoutQuestions(raw: unknown): DependentAddOnCheckoutQuestion[] {
|
|
347
|
+
if (!Array.isArray(raw)) return [];
|
|
348
|
+
const out: DependentAddOnCheckoutQuestion[] = [];
|
|
349
|
+
for (const item of raw) {
|
|
350
|
+
if (!item || typeof item !== 'object') continue;
|
|
351
|
+
const o = item as Record<string, unknown>;
|
|
352
|
+
const id = pickStr(o, 'id') ?? '';
|
|
353
|
+
if (!id) continue;
|
|
354
|
+
const typeRaw = (pickStr(o, 'type') ?? 'TEXT').toUpperCase();
|
|
355
|
+
const type: DependentAddOnCheckoutQuestionType =
|
|
356
|
+
typeRaw === 'CHECKBOX' ? 'CHECKBOX' : 'TEXT';
|
|
357
|
+
const label = pickStr(o, 'label') ?? id;
|
|
358
|
+
const required = Boolean(o.required ?? o.Required);
|
|
359
|
+
const maxLength =
|
|
360
|
+
typeof o.maxLength === 'number'
|
|
361
|
+
? o.maxLength
|
|
362
|
+
: typeof o.max_length === 'number'
|
|
363
|
+
? o.max_length
|
|
364
|
+
: undefined;
|
|
365
|
+
const placeholder = pickStr(o, 'placeholder', 'placeholder_text') ?? null;
|
|
366
|
+
const helpText = pickStr(o, 'helpText', 'help_text') ?? null;
|
|
367
|
+
out.push({
|
|
368
|
+
id,
|
|
369
|
+
type,
|
|
370
|
+
label,
|
|
371
|
+
required,
|
|
372
|
+
maxLength: maxLength ?? null,
|
|
373
|
+
placeholder,
|
|
374
|
+
helpText,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface CreateDependentAddOnPaymentIntentBody {
|
|
381
|
+
companyId: string;
|
|
382
|
+
primaryBookingReference: string;
|
|
383
|
+
lastName: string;
|
|
384
|
+
dependentAddOnProductId: string;
|
|
385
|
+
dependentAddOnProductOptionId?: string;
|
|
386
|
+
offeringId: string;
|
|
387
|
+
slotStart: string;
|
|
388
|
+
slotEnd: string;
|
|
389
|
+
quantity: number;
|
|
390
|
+
idempotencyKey: string;
|
|
391
|
+
customerEmail?: string;
|
|
392
|
+
customerFirstName?: string;
|
|
393
|
+
customerLastName?: string;
|
|
394
|
+
/** Keyed by question id; checkboxes use "true" / "false". */
|
|
395
|
+
checkoutAnswers?: Record<string, string>;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export interface CreateDependentAddOnPaymentIntentResult {
|
|
399
|
+
clientSecret?: string;
|
|
400
|
+
totalAmount?: number;
|
|
401
|
+
currency?: string;
|
|
402
|
+
/** Pre-tax subtotal (same as TicketBooth pricing config + GST logic) */
|
|
403
|
+
subtotalAmount?: number;
|
|
404
|
+
/** GST / tax included in totalAmount for CAD, etc. */
|
|
405
|
+
taxAmount?: number;
|
|
406
|
+
/** Primary booking customer last name (for manage-booking redirect). */
|
|
407
|
+
customerLastName?: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Server-priced Stripe PaymentIntent for a dependent add-on. After payment succeeds,
|
|
412
|
+
* TicketBooth webhook creates the dependent add-on booking (same idempotency key).
|
|
413
|
+
*/
|
|
414
|
+
export async function createDependentAddOnPaymentIntent(
|
|
415
|
+
body: CreateDependentAddOnPaymentIntentBody
|
|
416
|
+
): Promise<CreateDependentAddOnPaymentIntentResult> {
|
|
417
|
+
const endpoint = '/checkout/dependent-add-on-payment-intent';
|
|
418
|
+
let res: Response;
|
|
419
|
+
try {
|
|
420
|
+
res = await fetch(`${API_BASE}${endpoint}`, {
|
|
421
|
+
method: 'POST',
|
|
422
|
+
headers: getAuthHeaders(),
|
|
423
|
+
body: JSON.stringify(body),
|
|
424
|
+
});
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
427
|
+
throw new Error(`Unable to start payment. ${msg}`);
|
|
428
|
+
}
|
|
429
|
+
const payload = await parseJsonSafely(res);
|
|
430
|
+
if (!res.ok) {
|
|
431
|
+
throw new Error(messageFromPayload(payload, `Could not start payment (HTTP ${res.status})`));
|
|
432
|
+
}
|
|
433
|
+
return coercePaymentIntentResult(payload);
|
|
434
|
+
}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe environment variable access
|
|
3
|
+
* Direct access to process.env for Next.js build-time replacement
|
|
4
|
+
* This ensures NEXT_PUBLIC_* variables are properly embedded at build time
|
|
5
|
+
*/
|
|
6
|
+
const getApiUrl = (): string => {
|
|
7
|
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
8
|
+
|
|
9
|
+
if (!apiUrl) {
|
|
10
|
+
if (process.env.NODE_ENV === 'production') {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'NEXT_PUBLIC_API_URL environment variable is required for production builds. ' +
|
|
13
|
+
'Set it in your .env.local file or build environment.'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
// Development fallback - warn but allow
|
|
17
|
+
console.warn(
|
|
18
|
+
'⚠️ NEXT_PUBLIC_API_URL not set. Using localhost fallback. ' +
|
|
19
|
+
'Set NEXT_PUBLIC_API_URL in .env.local for production API.'
|
|
20
|
+
);
|
|
21
|
+
return 'http://localhost:3001';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return apiUrl;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const getGoogleMapsApiKey = (): string => {
|
|
28
|
+
return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const getStripePublishableKey = (): string => {
|
|
32
|
+
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '';
|
|
33
|
+
return key;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Base64-encoded `user:password` for TicketBooth Basic auth (not including the "Basic " prefix).
|
|
38
|
+
* Some endpoints (e.g. get-availabilities, reserve) require this; others may work without it.
|
|
39
|
+
*/
|
|
40
|
+
const getBasicAuth = (): string => {
|
|
41
|
+
return process.env.NEXT_PUBLIC_BASIC_AUTH ?? '';
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Via Via company ID in TicketBooth (for API calls). */
|
|
45
|
+
const getCompanyId = (): string => {
|
|
46
|
+
return process.env.NEXT_PUBLIC_COMPANY_ID ?? 'c_LFU0Vx9hS5v3';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** GA4 Measurement ID (e.g. G-XXXXXXXXXX). Use same as backend for deduplication. */
|
|
50
|
+
const getGa4MeasurementId = (): string => {
|
|
51
|
+
return process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID ?? 'G-BVCCYBSHP8';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Meta Pixel ID (e.g. 123456789012345). Use same as backend for deduplication. */
|
|
55
|
+
const getMetaPixelId = (): string => {
|
|
56
|
+
return process.env.NEXT_PUBLIC_META_PIXEL_ID ?? '';
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Where `/videos/…` and other public assets live when booking is embedded off viavia.com (Ticketbooth, etc.). Empty = same-origin relative paths. */
|
|
60
|
+
const getViaviaStaticOrigin = (): string => {
|
|
61
|
+
return process.env.NEXT_PUBLIC_VIAVIA_STATIC_ORIGIN?.trim() ?? '';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** App environment: development, staging, or production. Used for analytics, feature flags, etc. */
|
|
65
|
+
export type AppEnv = 'development' | 'staging' | 'production';
|
|
66
|
+
|
|
67
|
+
const getAppEnv = (): AppEnv => {
|
|
68
|
+
const raw = process.env.NEXT_PUBLIC_APP_ENV?.toLowerCase();
|
|
69
|
+
if (raw === 'production' || raw === 'staging') return raw;
|
|
70
|
+
return 'development';
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const APP_ENV = getAppEnv();
|
|
74
|
+
|
|
75
|
+
/** True when running in production. Use for GA4, Meta Pixel, etc. */
|
|
76
|
+
export const isProduction = (): boolean => APP_ENV === 'production';
|
|
77
|
+
|
|
78
|
+
/** True when running in staging. */
|
|
79
|
+
export const isStaging = (): boolean => APP_ENV === 'staging';
|
|
80
|
+
|
|
81
|
+
/** True when running in development (local or when NEXT_PUBLIC_APP_ENV is unset). */
|
|
82
|
+
export const isDevelopment = (): boolean => APP_ENV === 'development';
|
|
83
|
+
|
|
84
|
+
/** True when purchase events should only log to console (dev/staging). Phase 2: production sends to GA4/Meta. */
|
|
85
|
+
export const shouldLogPurchaseToConsole = (): boolean =>
|
|
86
|
+
APP_ENV === 'development' || APP_ENV === 'staging';
|
|
87
|
+
|
|
88
|
+
/** True when running on localhost (runtime check). Use to avoid sending to GA4/Meta when testing prod build locally. */
|
|
89
|
+
export const isLocalhost = (): boolean =>
|
|
90
|
+
typeof window !== 'undefined' &&
|
|
91
|
+
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
|
92
|
+
|
|
93
|
+
export const ENV = {
|
|
94
|
+
API_URL: getApiUrl(),
|
|
95
|
+
GOOGLE_MAPS_API_KEY: getGoogleMapsApiKey(),
|
|
96
|
+
STRIPE_PUBLISHABLE_KEY: getStripePublishableKey(),
|
|
97
|
+
BASIC_AUTH: getBasicAuth(),
|
|
98
|
+
COMPANY_ID: getCompanyId(),
|
|
99
|
+
GA4_MEASUREMENT_ID: getGa4MeasurementId(),
|
|
100
|
+
META_PIXEL_ID: getMetaPixelId(),
|
|
101
|
+
VIAVIA_STATIC_ORIGIN: getViaviaStaticOrigin(),
|
|
102
|
+
} as const;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manage-booking post-checkout helpers.
|
|
3
|
+
*
|
|
4
|
+
* Next.js `router.replace` remounts the page; React state for a success banner is lost.
|
|
5
|
+
* We stash a one-time marker in sessionStorage immediately before cleaning the URL
|
|
6
|
+
* (only paid *change booking* Stripe returns). New bookings use `reservationRef` → `ref`
|
|
7
|
+
* and never set this flag.
|
|
8
|
+
*
|
|
9
|
+
* We intentionally do not poll the booking API here: one landing fetch + optional remount
|
|
10
|
+
* fetch after `replace` is enough; avoid timers/retry loops that feel like “random” refreshes.
|
|
11
|
+
*/
|
|
12
|
+
const STORAGE_KEY = 'viavia:manage-booking:flash:v1';
|
|
13
|
+
|
|
14
|
+
export const MANAGE_BOOKING_QUERY_FROM = 'from';
|
|
15
|
+
/** Only paid *change booking* Stripe returns add this; new bookings must not use it. */
|
|
16
|
+
export const MANAGE_BOOKING_FROM_CHANGE_PAYMENT = 'change_payment';
|
|
17
|
+
|
|
18
|
+
/** Success copy for any self-serve booking change (no-pay, paid-after-Stripe, etc.). */
|
|
19
|
+
export function manageBookingChangeSuccessMessage(
|
|
20
|
+
preview: {
|
|
21
|
+
dateChanged?: boolean;
|
|
22
|
+
ticketsChanged?: boolean;
|
|
23
|
+
} | null,
|
|
24
|
+
): string {
|
|
25
|
+
const updates: string[] = [];
|
|
26
|
+
if (preview?.dateChanged) updates.push('date/time');
|
|
27
|
+
if (preview?.ticketsChanged) updates.push('ticket selection');
|
|
28
|
+
const suffix =
|
|
29
|
+
updates.length > 0 ? ` Updated ${updates.join(' and ')}.` : '';
|
|
30
|
+
return `Your booking was updated successfully.${suffix}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ManageBookingFlashPayload = {
|
|
34
|
+
v: 1;
|
|
35
|
+
kind: 'change_payment_success';
|
|
36
|
+
t: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function setManageBookingFlashChangePaymentSuccess(): void {
|
|
40
|
+
if (typeof window === 'undefined') return;
|
|
41
|
+
try {
|
|
42
|
+
const payload: ManageBookingFlashPayload = {
|
|
43
|
+
v: 1,
|
|
44
|
+
kind: 'change_payment_success',
|
|
45
|
+
t: Date.now(),
|
|
46
|
+
};
|
|
47
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
|
48
|
+
} catch {
|
|
49
|
+
/* private mode / quota */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read and clear. Returns user-facing message or null. */
|
|
54
|
+
export function consumeManageBookingFlash(): string | null {
|
|
55
|
+
if (typeof window === 'undefined') return null;
|
|
56
|
+
try {
|
|
57
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
58
|
+
if (!raw) return null;
|
|
59
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
60
|
+
const j = JSON.parse(raw) as ManageBookingFlashPayload;
|
|
61
|
+
if (j?.v === 1 && j.kind === 'change_payment_success') {
|
|
62
|
+
return manageBookingChangeSuccessMessage(null);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|