@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0
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 +29 -2
- package/src/assets/icons/minus.svg +7 -0
- package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
- package/src/assets/icons/plus.svg +3 -0
- package/src/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2264 -0
- package/src/components/BookingWidget.tsx +302 -0
- package/src/components/ManageBookingView.tsx +437 -0
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/booking/Calendar.tsx +1123 -0
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/booking/CheckoutModal.tsx +451 -0
- package/src/components/booking/CurrencySwitcher.tsx +81 -0
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/ErrorBoundary.tsx +63 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/booking/ItineraryBuilder.tsx +82 -0
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/booking/PickupLocationSelector.tsx +1566 -0
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/booking/PriceBreakdown.tsx +154 -0
- package/src/components/booking/PriceSummary.tsx +234 -0
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/booking/TermsAcceptance.tsx +111 -0
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +38 -0
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/BookingAppContext.tsx +134 -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/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/index.ts +79 -0
- package/src/lib/analytics.ts +197 -0
- 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/theme.ts +83 -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 +96 -0
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/photo-packages.ts +75 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +282 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -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/text-style.css +56 -0
- package/src/utils/currency-converter.ts +101 -0
- package/tsconfig.json +8 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend analytics for GA4 and Meta Pixel.
|
|
3
|
+
* Events deduplicate with backend using transaction_id / eventID = Stripe paymentIntent.id.
|
|
4
|
+
* - development/staging: log to console only (no GA4/Meta)
|
|
5
|
+
* - production: send to GA4/Meta when consent is granted
|
|
6
|
+
*/
|
|
7
|
+
import { ENV, isLocalhost, isProduction, shouldLogPurchaseToConsole } from '@/lib/env';
|
|
8
|
+
|
|
9
|
+
const CONSENT_KEY = 'cookie-consent';
|
|
10
|
+
const PENDING_PURCHASE_KEY = 'pending_purchase';
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
dataLayer?: unknown[];
|
|
15
|
+
gtag?: (...args: unknown[]) => void;
|
|
16
|
+
fbq?: (...args: unknown[]) => void;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Check if user has granted cookie consent. */
|
|
21
|
+
export function hasAnalyticsConsent(): boolean {
|
|
22
|
+
if (typeof window === 'undefined') return false;
|
|
23
|
+
try {
|
|
24
|
+
return localStorage.getItem(CONSENT_KEY) === 'granted';
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Mark consent as granted (call from cookie banner on Accept). */
|
|
31
|
+
export function setAnalyticsConsentGranted(): void {
|
|
32
|
+
if (typeof window === 'undefined') return;
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(CONSENT_KEY, 'granted');
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Store purchase data before Stripe redirect.
|
|
42
|
+
* Success page reads payment_intent from URL and fires purchase with transaction_id = payment_intent.
|
|
43
|
+
*/
|
|
44
|
+
export function storePendingPurchase(value: number, currency: string): void {
|
|
45
|
+
if (typeof window === 'undefined') return;
|
|
46
|
+
try {
|
|
47
|
+
sessionStorage.setItem(
|
|
48
|
+
PENDING_PURCHASE_KEY,
|
|
49
|
+
JSON.stringify({ value, currency })
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read and clear pending purchase from sessionStorage. */
|
|
57
|
+
export function consumePendingPurchase(): { value: number; currency: string } | null {
|
|
58
|
+
if (typeof window === 'undefined') return null;
|
|
59
|
+
try {
|
|
60
|
+
const raw = sessionStorage.getItem(PENDING_PURCHASE_KEY);
|
|
61
|
+
sessionStorage.removeItem(PENDING_PURCHASE_KEY);
|
|
62
|
+
if (!raw) return null;
|
|
63
|
+
const parsed = JSON.parse(raw) as { value?: number; currency?: string };
|
|
64
|
+
if (typeof parsed?.value === 'number' && typeof parsed?.currency === 'string') {
|
|
65
|
+
return { value: parsed.value, currency: parsed.currency };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeGtag(...args: unknown[]): void {
|
|
74
|
+
if (typeof window !== 'undefined' && window.gtag) {
|
|
75
|
+
window.gtag(...args);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeFbq(...args: unknown[]): void {
|
|
80
|
+
if (typeof window !== 'undefined' && window.fbq) {
|
|
81
|
+
window.fbq(...args);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fire view_item when user selects or views a product. */
|
|
86
|
+
export function trackViewItem(
|
|
87
|
+
productId: string,
|
|
88
|
+
productName: string,
|
|
89
|
+
price: number,
|
|
90
|
+
currency: string
|
|
91
|
+
): void {
|
|
92
|
+
const data = { productId, productName, price, currency };
|
|
93
|
+
|
|
94
|
+
if (isLocalhost() || shouldLogPurchaseToConsole()) {
|
|
95
|
+
console.log('analytics view_item', data);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!hasAnalyticsConsent()) return;
|
|
100
|
+
|
|
101
|
+
if (ENV.GA4_MEASUREMENT_ID) {
|
|
102
|
+
safeGtag('event', 'view_item', {
|
|
103
|
+
items: [{ item_id: productId, item_name: productName, price, currency }],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (ENV.META_PIXEL_ID) {
|
|
107
|
+
safeFbq('track', 'ViewContent', {
|
|
108
|
+
content_ids: [productId],
|
|
109
|
+
content_name: productName,
|
|
110
|
+
content_type: 'product',
|
|
111
|
+
value: price,
|
|
112
|
+
currency,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface BeginCheckoutItem {
|
|
118
|
+
id: string;
|
|
119
|
+
name: string;
|
|
120
|
+
qty: number;
|
|
121
|
+
price: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Fire begin_checkout when checkout modal opens. */
|
|
125
|
+
export function trackBeginCheckout(
|
|
126
|
+
value: number,
|
|
127
|
+
currency: string,
|
|
128
|
+
items: BeginCheckoutItem[]
|
|
129
|
+
): void {
|
|
130
|
+
const data = { value, currency, items };
|
|
131
|
+
|
|
132
|
+
if (isLocalhost() || shouldLogPurchaseToConsole()) {
|
|
133
|
+
console.log('analytics begin_checkout', data);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!hasAnalyticsConsent()) return;
|
|
138
|
+
|
|
139
|
+
if (ENV.GA4_MEASUREMENT_ID) {
|
|
140
|
+
safeGtag('event', 'begin_checkout', {
|
|
141
|
+
value,
|
|
142
|
+
currency,
|
|
143
|
+
items: items.map((i) => ({
|
|
144
|
+
item_id: i.id,
|
|
145
|
+
item_name: i.name,
|
|
146
|
+
quantity: i.qty,
|
|
147
|
+
price: i.price,
|
|
148
|
+
})),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (ENV.META_PIXEL_ID) {
|
|
152
|
+
safeFbq('track', 'InitiateCheckout', {
|
|
153
|
+
value,
|
|
154
|
+
currency,
|
|
155
|
+
content_ids: items.map((i) => i.id),
|
|
156
|
+
content_type: 'product',
|
|
157
|
+
num_items: items.reduce((s, i) => s + i.qty, 0),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fire purchase on success page.
|
|
164
|
+
* transactionId MUST be Stripe paymentIntent.id (from URL param payment_intent) for dedup with backend.
|
|
165
|
+
*/
|
|
166
|
+
export function trackPurchase(
|
|
167
|
+
transactionId: string,
|
|
168
|
+
value: number,
|
|
169
|
+
currency: string,
|
|
170
|
+
items: Array<{ item_id: string; item_name: string; quantity: number; price: number }> = []
|
|
171
|
+
): void {
|
|
172
|
+
const data = { transaction_id: transactionId, value, currency, items };
|
|
173
|
+
|
|
174
|
+
if (isLocalhost() || shouldLogPurchaseToConsole()) {
|
|
175
|
+
console.log('analytics purchase', data);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!hasAnalyticsConsent()) return;
|
|
180
|
+
|
|
181
|
+
if (ENV.GA4_MEASUREMENT_ID) {
|
|
182
|
+
safeGtag('event', 'purchase', {
|
|
183
|
+
transaction_id: transactionId,
|
|
184
|
+
value,
|
|
185
|
+
currency,
|
|
186
|
+
items,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (ENV.META_PIXEL_ID) {
|
|
190
|
+
safeFbq('track', 'Purchase', {
|
|
191
|
+
value,
|
|
192
|
+
currency,
|
|
193
|
+
order_id: transactionId,
|
|
194
|
+
eventID: transactionId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel values the API persists on reservations/bookings (aligned with ticketbooth-be
|
|
3
|
+
* `BookingMarketingSource` + `SourceTrackingService` auto-detect: WEBSITE, AFFILIATE, DASHBOARD,
|
|
4
|
+
* GYG, VIATOR, plus dynamic uppercase `utm_source` strings when no fixed bucket applies).
|
|
5
|
+
*/
|
|
6
|
+
export enum KnownBookingSource {
|
|
7
|
+
WEBSITE = 'WEBSITE',
|
|
8
|
+
/** Main-site partner embed (e.g. `/partner/{slug}`); not the dedicated `booking.*` portal app. */
|
|
9
|
+
PUBLIC_PARTNER_WEBSITE = 'PUBLIC_PARTNER_WEBSITE',
|
|
10
|
+
PARTNER_PORTAL = 'PARTNER_PORTAL',
|
|
11
|
+
AFFILIATE = 'AFFILIATE',
|
|
12
|
+
DASHBOARD = 'DASHBOARD',
|
|
13
|
+
GYG = 'GYG',
|
|
14
|
+
VIATOR = 'VIATOR',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Default reserve/checkout `source` when the client does not send a more specific channel. */
|
|
18
|
+
export const DEFAULT_BOOKING_SOURCE = KnownBookingSource.WEBSITE;
|
|
19
|
+
|
|
20
|
+
/** Client `source` when booking through the signed-in partner org / marketing partner flows. */
|
|
21
|
+
export const PARTNER_PORTAL_BOOKING_SOURCE = KnownBookingSource.PARTNER_PORTAL;
|
|
22
|
+
|
|
23
|
+
const PUBLIC_BOOKING_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when metadata carries a partner org id or a normalized public marketing slug (same shape
|
|
27
|
+
* as backend `publicBookingSlug`), independent of hostname or URL path — use with route-level
|
|
28
|
+
* `canonicalAttribution.partnerSlug` so localhost and stripped URLs still attribute.
|
|
29
|
+
*/
|
|
30
|
+
export function mergedMetadataImpliesPartnerPortal(
|
|
31
|
+
merged: Partial<{ partnerId?: string; partnerSlug?: string }>,
|
|
32
|
+
): boolean {
|
|
33
|
+
const pid = typeof merged.partnerId === 'string' ? merged.partnerId.trim() : '';
|
|
34
|
+
if (pid.startsWith('par_')) return true;
|
|
35
|
+
const slug = typeof merged.partnerSlug === 'string' ? merged.partnerSlug.trim().toLowerCase() : '';
|
|
36
|
+
return slug.length > 0 && PUBLIC_BOOKING_SLUG_RE.test(slug);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fixed client channel from product / option identifiers (GYG / Viator embeds). Everything else
|
|
41
|
+
* is treated as main-site {@link KnownBookingSource.WEBSITE}; AFFILIATE/DASHBOARD are set server-side.
|
|
42
|
+
*/
|
|
43
|
+
export function inferClientBookingSourceFromProductIds(
|
|
44
|
+
productId: string,
|
|
45
|
+
productOptionId?: string | null,
|
|
46
|
+
): KnownBookingSource {
|
|
47
|
+
const haystack = `${productId} ${productOptionId ?? ''}`.toLowerCase();
|
|
48
|
+
if (haystack.includes('gyg_')) return KnownBookingSource.GYG;
|
|
49
|
+
if (haystack.includes('viator_')) return KnownBookingSource.VIATOR;
|
|
50
|
+
return KnownBookingSource.WEBSITE;
|
|
51
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CheckoutBreakdown, CheckoutReceiptLine } from '@/lib/booking-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Round to 2 decimal places. Used so breakdown amounts and totals are stable
|
|
5
|
+
* and pass backend validation (sum of line items ≈ totalAmount within 0.02).
|
|
6
|
+
*/
|
|
7
|
+
export function round2(n: number): number {
|
|
8
|
+
return Math.round(n * 100) / 100;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CheckoutLineInput {
|
|
12
|
+
label: string;
|
|
13
|
+
amount: number;
|
|
14
|
+
type: string;
|
|
15
|
+
quantity?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Line types for checkout breakdown. ADDITIONAL_HOURS reserved for future private shuttle extra hours. */
|
|
19
|
+
export const CheckoutLineType = {
|
|
20
|
+
TICKET: 'TICKET',
|
|
21
|
+
DEPOSIT: 'DEPOSIT',
|
|
22
|
+
FEE: 'FEE',
|
|
23
|
+
RETURN_OPTION: 'return',
|
|
24
|
+
CANCELLATION_UPGRADE: 'cancellation',
|
|
25
|
+
TAX: 'TAX',
|
|
26
|
+
PROMO_CODE: 'PROMO_CODE',
|
|
27
|
+
ROUNDING: 'ROUNDING',
|
|
28
|
+
ADDITIONAL_HOURS: 'ADDITIONAL_HOURS', // Future: private shuttle extra hours line
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export interface BuildCheckoutBreakdownParams {
|
|
32
|
+
/** Line items in display order (tickets, return, cancellation, fees, tax, promo, etc.) */
|
|
33
|
+
lines: CheckoutLineInput[];
|
|
34
|
+
/** Total amount to charge (will be rounded to 2 decimals). */
|
|
35
|
+
totalAmount: number;
|
|
36
|
+
currency: string;
|
|
37
|
+
/** Label for rounding line when added (e.g. "Rounding"). */
|
|
38
|
+
roundingLabel: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds a CheckoutBreakdown from the given lines and total.
|
|
43
|
+
* Rounds each line amount to 2 decimals; if the sum differs from total by more than 0.02,
|
|
44
|
+
* adds a rounding line so the backend validator accepts it.
|
|
45
|
+
* Reused by BookingFlow and PrivateShuttleBookingFlow so Stripe and /manage match the UI.
|
|
46
|
+
*/
|
|
47
|
+
export function buildCheckoutBreakdown(params: BuildCheckoutBreakdownParams): CheckoutBreakdown {
|
|
48
|
+
const { lines, totalAmount, currency, roundingLabel } = params;
|
|
49
|
+
const totalRounded = round2(totalAmount);
|
|
50
|
+
const lineItems: CheckoutReceiptLine[] = lines.map((line) => ({
|
|
51
|
+
label: line.label,
|
|
52
|
+
amount: round2(line.amount),
|
|
53
|
+
type: line.type,
|
|
54
|
+
quantity: line.quantity,
|
|
55
|
+
}));
|
|
56
|
+
const sumLines = lineItems.reduce((s, l) => s + l.amount, 0);
|
|
57
|
+
if (Math.abs(sumLines - totalRounded) > 0.02) {
|
|
58
|
+
lineItems.push({
|
|
59
|
+
label: roundingLabel,
|
|
60
|
+
amount: round2(totalRounded - sumLines),
|
|
61
|
+
type: 'ROUNDING',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
lineItems,
|
|
66
|
+
totalAmount: totalRounded,
|
|
67
|
+
currency,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One opaque id per browser tab/session for joining client telemetry → TicketBooth Lambda logs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const BOOKING_CORRELATION_HEADER = 'X-Correlation-Id';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'tb_booking_correlation_id';
|
|
8
|
+
|
|
9
|
+
function newCorrelationId(): string {
|
|
10
|
+
try {
|
|
11
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
12
|
+
return crypto.randomUUID();
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
/* fall through */
|
|
16
|
+
}
|
|
17
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable for the lifetime of this tab's sessionStorage (until the tab closes / storage cleared). */
|
|
21
|
+
export function getOrCreateBookingCorrelationId(): string {
|
|
22
|
+
if (typeof window === 'undefined') return '';
|
|
23
|
+
try {
|
|
24
|
+
let id = sessionStorage.getItem(STORAGE_KEY);
|
|
25
|
+
if (!id) {
|
|
26
|
+
id = newCorrelationId();
|
|
27
|
+
sessionStorage.setItem(STORAGE_KEY, id);
|
|
28
|
+
}
|
|
29
|
+
return id;
|
|
30
|
+
} catch {
|
|
31
|
+
return newCorrelationId();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Merge correlation header into outbound API headers (browser only). */
|
|
36
|
+
export function withBookingCorrelationId(
|
|
37
|
+
headers: Record<string, string>
|
|
38
|
+
): Record<string, string> {
|
|
39
|
+
if (typeof window === 'undefined') return headers;
|
|
40
|
+
const id = getOrCreateBookingCorrelationId();
|
|
41
|
+
if (!id) return headers;
|
|
42
|
+
return {
|
|
43
|
+
...headers,
|
|
44
|
+
[BOOKING_CORRELATION_HEADER]: id,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Configuration
|
|
3
|
+
*
|
|
4
|
+
* This file sets up the internationalization configuration.
|
|
5
|
+
* Currently supports English (en) as default, ready to add more languages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const locales = ['en', 'fr'] as const;
|
|
9
|
+
export type Locale = (typeof locales)[number];
|
|
10
|
+
|
|
11
|
+
export const defaultLocale: Locale = 'en';
|
|
12
|
+
|
|
13
|
+
// Language display names
|
|
14
|
+
export const languageNames: Record<Locale, string> = {
|
|
15
|
+
en: 'English',
|
|
16
|
+
fr: 'Français',
|
|
17
|
+
// Add more languages here:
|
|
18
|
+
// es: 'Español',
|
|
19
|
+
// de: 'Deutsch',
|
|
20
|
+
};
|
|
21
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* i18n Utilities
|
|
5
|
+
*
|
|
6
|
+
* Simple translation utilities for client components.
|
|
7
|
+
* For server components, use next-intl directly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, createContext, useContext } from 'react';
|
|
11
|
+
import type { ReactNode } from 'react';
|
|
12
|
+
import messagesEn from './messages/en.json';
|
|
13
|
+
import messagesFr from './messages/fr.json';
|
|
14
|
+
import { defaultLocale, locales, type Locale } from './config';
|
|
15
|
+
|
|
16
|
+
type Messages = typeof messagesEn;
|
|
17
|
+
|
|
18
|
+
const messages: Record<Locale, Messages> = {
|
|
19
|
+
en: messagesEn,
|
|
20
|
+
fr: messagesFr,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Context for locale
|
|
24
|
+
const LocaleContext = createContext<{
|
|
25
|
+
locale: Locale;
|
|
26
|
+
setLocale: (locale: Locale) => void;
|
|
27
|
+
}>({
|
|
28
|
+
locale: defaultLocale,
|
|
29
|
+
setLocale: () => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Provider component
|
|
33
|
+
export function LocaleProvider({ children }: { children: ReactNode }) {
|
|
34
|
+
// Always start with default locale to ensure server/client match
|
|
35
|
+
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
|
|
36
|
+
|
|
37
|
+
// Load from localStorage after hydration to avoid SSR mismatch
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
try {
|
|
40
|
+
const saved = localStorage.getItem('booking-locale') as Locale | null;
|
|
41
|
+
// Validate that saved locale is in our supported locales array
|
|
42
|
+
if (saved && locales.includes(saved)) {
|
|
43
|
+
setLocaleState(saved);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// localStorage might be disabled or throw errors
|
|
47
|
+
console.warn('Failed to read locale from localStorage:', error);
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const setLocale = (newLocale: Locale) => {
|
|
52
|
+
// Validate locale before setting
|
|
53
|
+
if (!locales.includes(newLocale)) {
|
|
54
|
+
console.warn(`Invalid locale: ${newLocale}. Falling back to ${defaultLocale}`);
|
|
55
|
+
setLocaleState(defaultLocale);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setLocaleState(newLocale);
|
|
60
|
+
if (typeof window !== 'undefined') {
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem('booking-locale', newLocale);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// localStorage might be disabled or full
|
|
65
|
+
console.warn('Failed to save locale to localStorage:', error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<LocaleContext.Provider value={{ locale, setLocale }}>
|
|
72
|
+
{children}
|
|
73
|
+
</LocaleContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Hook to use translations
|
|
78
|
+
export function useTranslations() {
|
|
79
|
+
const { locale } = useContext(LocaleContext);
|
|
80
|
+
|
|
81
|
+
// Ensure we have a valid locale, fallback to default if invalid
|
|
82
|
+
const validLocale = locales.includes(locale) ? locale : defaultLocale;
|
|
83
|
+
const currentMessages = messages[validLocale] || messages[defaultLocale];
|
|
84
|
+
|
|
85
|
+
const t = (key: string, params?: Record<string, string | number>): string => {
|
|
86
|
+
if (!key || typeof key !== 'string') {
|
|
87
|
+
console.warn('Invalid translation key:', key);
|
|
88
|
+
return String(key || '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const keys = key.split('.');
|
|
92
|
+
let value: unknown = currentMessages;
|
|
93
|
+
|
|
94
|
+
for (const k of keys) {
|
|
95
|
+
if (value === null || value === undefined) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
// Safe property access on unknown type
|
|
99
|
+
value = (value as Record<string, unknown>)[k];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (value === undefined || value === null) {
|
|
103
|
+
// In development, show the key to help identify missing translations
|
|
104
|
+
if (process.env.NODE_ENV === 'development') {
|
|
105
|
+
console.warn(`Translation key not found: ${key} (locale: ${validLocale})`);
|
|
106
|
+
}
|
|
107
|
+
return key;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ensure value is a string
|
|
111
|
+
if (typeof value !== 'string') {
|
|
112
|
+
console.warn(`Translation value is not a string for key: ${key}`);
|
|
113
|
+
return key;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Simple parameter replacement
|
|
117
|
+
if (params) {
|
|
118
|
+
return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
|
|
119
|
+
const paramValue = params[paramKey];
|
|
120
|
+
return paramValue !== undefined && paramValue !== null
|
|
121
|
+
? String(paramValue)
|
|
122
|
+
: match;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return value;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return { t };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Hook to use locale switching
|
|
133
|
+
export function useLocale() {
|
|
134
|
+
return useContext(LocaleContext);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Type-safe translation key helper
|
|
138
|
+
export type TranslationKey =
|
|
139
|
+
| `common.${keyof Messages['common']}`
|
|
140
|
+
| `booking.${keyof Messages['booking']}`
|
|
141
|
+
| `pickup.${keyof Messages['pickup']}`
|
|
142
|
+
| `calendar.${keyof Messages['calendar']}`
|
|
143
|
+
| `products.${keyof Messages['products']}`;
|
|
144
|
+
|