@ticketboothapp/booking 0.1.7 → 0.1.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "sideEffects": false,
6
6
  "publishConfig": {
@@ -1,349 +1,65 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
4
- import { getProducts, type Product, setAuthToken } from '@/lib/api';
5
- import { ProductList } from '@/components/ProductList';
6
- import { BookingFlow } from '@/components/BookingFlow';
7
- import { PrivateShuttleBookingFlow } from '@/components/PrivateShuttleBookingFlow';
8
- import { LanguageSwitcher } from '@/components/LanguageSwitcher';
9
- import { CurrencySwitcher, useCurrency, type Currency } from '@/components/CurrencySwitcher';
10
- import { ErrorBoundary } from '@/components/ErrorBoundary';
11
- import { CompanyProvider } from '@/contexts/CompanyContext';
12
- import { BookingAppProvider, type BookingAppMode, type BookingAppPermissions, type ManageParams } from '@/contexts/BookingAppContext';
13
- import { mergeTheme, themeToCssVars, type BookingTheme } from '@/lib/theme';
14
-
15
- // ============ Navigation Stack ============
16
-
17
- type Step = 'products' | 'booking';
18
-
19
- interface NavState {
20
- step: Step;
21
- selectedProduct: Product | null;
22
- }
23
-
24
- // ============ Booking Widget Props ============
3
+ import { useMemo, type CSSProperties } from 'react';
4
+ import type { BookingFlowUiOptions } from './booking/booking-flow-ui';
5
+ import type { BookingSourceMetadata } from '../lib/booking/source-metadata';
6
+ import type { BookingAppMode, BookingAppPermissions, ManageParams } from '../contexts/BookingAppContext';
25
7
 
26
8
  export interface BookingWidgetProps {
27
- /**
28
- * Optional product ID to start with a specific product selected
29
- */
30
9
  initialProductId?: string;
31
-
32
- /**
33
- * Optional initial currency
34
- */
35
- initialCurrency?: Currency;
36
-
37
- /**
38
- * Whether to show the header (default: true)
39
- */
40
- showHeader?: boolean;
41
-
42
- /**
43
- * Optional JWT token for authentication (when embedded in provider dashboard)
44
- * If provided, will use Bearer token auth instead of Basic Auth
45
- */
46
10
  authToken?: string | null;
47
-
48
- /**
49
- * Callback when a product is selected
50
- */
51
- onProductSelect?: (product: Product) => void;
52
-
53
- /**
54
- * Callback when booking is completed successfully
55
- */
56
11
  onBookingSuccess?: (data: { reservationReference: string; sessionId?: string }) => void;
57
-
58
- /**
59
- * Callback when user navigates back
60
- */
61
12
  onBack?: () => void;
62
-
63
- /**
64
- * Custom className for the container
65
- */
66
13
  className?: string;
67
-
68
- /**
69
- * Host context: which app is embedding the booking UI (e.g. 'standalone', 'provider-dashboard').
70
- * Used together with permissions to gate features like the price breakdown tooltip.
71
- */
72
14
  mode?: BookingAppMode;
73
-
74
- /**
75
- * Permissions for this embedding context (e.g. show price breakdown only for staff).
76
- * When not provided, defaults to standalone permissions (e.g. no price breakdown).
77
- */
78
15
  permissions?: Partial<BookingAppPermissions>;
79
-
80
- /**
81
- * Google Maps API key from the host. Pass the same value as NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
82
- * so the map works when the widget is embedded (avoids build-time env not reaching embedded code).
83
- */
84
16
  googleMapsApiKey?: string;
85
-
86
- /**
87
- * When set (e.g. in provider-dashboard), called instead of redirecting to /manage after booking;
88
- * host can show the manage-booking UI in a dialog.
89
- */
90
17
  onShowManage?: (params: ManageParams) => void;
91
-
92
- /**
93
- * When set, used as Stripe return_url so payment success lands on the host (e.g. dashboard)
94
- * instead of the booking app's /manage page.
95
- */
96
- getSuccessUrl?: (params: { reservationRef: string; lastName: string }) => string;
97
-
98
- /**
99
- * When false, the language selector is hidden. Host-controlled; default true.
100
- */
18
+ getSuccessUrl?: (params: { reservationRef: string; lastName: string; focusDate?: string }) => string;
101
19
  showLanguageSelector?: boolean;
102
-
103
- /**
104
- * Optional theme overrides (colors, fonts, etc.). When not provided, default booking theme is used.
105
- */
106
- theme?: Partial<BookingTheme>;
107
-
108
- /**
109
- * Change booking mode: when provided with initialProduct and onChangeBooking, skip product selection
110
- * and show the booking flow pre-filled for changing an existing booking.
111
- */
112
- initialProduct?: Product;
113
- /** In change mode: pass all products so user can switch to a different product. When provided, skips fetch. */
114
- products?: Product[];
115
- /** Company ID for change mode (used by CompanyProvider). Required when in change mode. */
116
- companyId?: string;
117
- /** Booking to change (provider dashboard). Required when initialProduct + onChangeBooking are set. */
118
- initialBooking?: {
119
- bookingReference: string;
120
- productId: string;
121
- availabilityId?: string;
122
- dateTime: string;
123
- originalTotalAmount?: number;
124
- originalCurrency?: string;
125
- bookingItems: Array<{ category: string; count: number }>;
126
- returnAvailabilityId?: string | null;
127
- pickupLocationId?: string | null;
128
- travelerHotel?: string | null;
129
- startTime?: string | null;
130
- privateShuttleDetails?: { passengerCount?: number };
131
- cancellationPolicyId?: string | null;
132
- promoCode?: string | null;
133
- additionalHoursCount?: number | null;
134
- addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
135
- };
136
- /** Called when user submits change. Receives the new booking data. Required for change mode. */
137
- onChangeBooking?: (data: {
138
- productId: string;
139
- dateTime: string;
140
- bookingItems: Array<{ category: string; count: number }>;
141
- returnAvailabilityId?: string | null;
142
- pickupLocationId?: string | null;
143
- travelerHotel?: string | null;
144
- startTime?: string | null;
145
- passengerCount?: number | null;
146
- childSafetySeatsCount?: number | null;
147
- foodRestrictions?: string | null;
148
- addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
149
- cancellationPolicyId?: string | null;
150
- promoCode?: string | null;
151
- /** Frontend-calculated new total (used by backend for delta/refund to avoid pricing mismatch). */
152
- newTotalAmount?: number;
153
- /** When true: apply change but keep original receipt/price — no charge, refund, or line item updates. */
154
- keepOriginalPrice?: boolean;
155
- additionalHoursCount?: number | null;
156
- }) => Promise<void>;
20
+ flowUi?: BookingFlowUiOptions;
21
+ bookingSourceAttribution?: Partial<BookingSourceMetadata>;
157
22
  }
158
23
 
159
- // ============ Booking Widget Component ============
24
+ const getBookingHost = (): string =>
25
+ process.env.NEXT_PUBLIC_BOOKING_WIDGET_URL || 'https://viaviamorainelake.com';
160
26
 
161
- /**
162
- * Embeddable booking widget component for React applications
163
- *
164
- * @example
165
- * ```tsx
166
- * <BookingWidget
167
- * initialProductId="prod_123"
168
- * onBookingSuccess={(data) => console.log('Booking complete!', data)}
169
- * />
170
- * ```
171
- */
172
27
  export function BookingWidget({
173
28
  initialProductId,
174
- initialProduct,
175
- initialBooking,
176
- onChangeBooking,
177
- products: productsProp,
178
- companyId,
179
- initialCurrency = 'CAD',
180
- showHeader = true,
181
- authToken,
182
- onProductSelect,
183
- onBookingSuccess,
184
- onBack,
185
29
  className = '',
186
30
  mode = 'standalone',
187
- permissions = {},
188
- googleMapsApiKey,
189
- onShowManage,
190
- getSuccessUrl,
191
31
  showLanguageSelector = true,
192
- theme: themeOverride,
32
+ googleMapsApiKey,
33
+ bookingSourceAttribution,
193
34
  }: BookingWidgetProps) {
194
- const { currency, setCurrency } = useCurrency();
195
- const theme = mergeTheme(themeOverride);
196
- const themeVars = themeToCssVars(theme);
197
-
198
- // Set auth token for API calls when provided (for embedded use in provider dashboard)
199
- useEffect(() => {
200
- setAuthToken(authToken || null);
201
- // Cleanup on unmount
202
- return () => setAuthToken(null);
203
- }, [authToken]);
204
-
205
- // Set initial currency if provided
206
- useEffect(() => {
207
- if (initialCurrency) {
208
- setCurrency(initialCurrency);
209
- }
210
- // eslint-disable-next-line react-hooks/exhaustive-deps
211
- }, []); // Only run on mount
212
- const isChangeMode = !!(initialProduct && initialBooking && onChangeBooking);
213
- const [navState, setNavState] = useState<NavState>(() => {
214
- if (isChangeMode && initialProduct) {
215
- return { step: 'booking', selectedProduct: initialProduct };
216
- }
217
- return { step: 'products', selectedProduct: null };
218
- });
219
- const [products, setProducts] = useState<Product[]>(() =>
220
- productsProp && productsProp.length > 0 ? productsProp : (isChangeMode && initialProduct ? [initialProduct] : [])
221
- );
222
- const [loadingProducts, setLoadingProducts] = useState(!(productsProp && productsProp.length > 0) && !isChangeMode);
223
- const [error, setError] = useState('');
224
-
225
- // Load initial product if specified (normal mode)
226
- useEffect(() => {
227
- if (isChangeMode) return;
228
- if (initialProductId && products.length > 0) {
229
- const product = products.find(p => p.productId === initialProductId || p.options?.some(o => o.optionId === initialProductId));
230
- if (product) {
231
- setNavState({ step: 'booking', selectedProduct: product });
232
- }
233
- }
234
- }, [isChangeMode, initialProductId, products]);
235
-
236
- useEffect(() => {
237
- if (productsProp && productsProp.length > 0) {
238
- setProducts(productsProp);
239
- setLoadingProducts(false);
240
- return;
241
- }
242
- if (isChangeMode) {
243
- setLoadingProducts(false);
244
- return;
245
- }
246
- async function fetchProducts() {
247
- try {
248
- const data = await getProducts();
249
- setProducts(data);
250
- } catch (err) {
251
- setError(err instanceof Error ? err.message : 'Failed to load products');
252
- } finally {
253
- setLoadingProducts(false);
254
- }
255
- }
256
- fetchProducts();
257
- }, [isChangeMode, productsProp]);
258
-
259
- const handleSelectProduct = useCallback((product: Product) => {
260
- setNavState({ step: 'booking', selectedProduct: product });
261
- onProductSelect?.(product);
262
- }, [onProductSelect]);
263
-
264
- const handleBack = useCallback(() => {
265
- if (isChangeMode) {
266
- // In change mode: go back to product selection (don't close dialog)
267
- setNavState({ step: 'products', selectedProduct: null });
268
- } else {
269
- setNavState({ step: 'products', selectedProduct: null });
270
- onBack?.();
271
- }
272
- }, [isChangeMode, onBack]);
273
-
274
- const handleBookingSuccess = useCallback((data: { reservationReference: string; sessionId?: string }) => {
275
- onBookingSuccess?.(data);
276
- }, [onBookingSuccess]);
35
+ const src = useMemo(() => {
36
+ // Route directly to customer booking (not partner portal root).
37
+ const url = new URL('/offers', getBookingHost());
38
+ if (initialProductId) url.searchParams.set('productId', initialProductId);
39
+ if (!showLanguageSelector) url.searchParams.set('showLanguageSelector', '0');
40
+ if (googleMapsApiKey) url.searchParams.set('gmapsKey', googleMapsApiKey);
41
+ if (mode) url.searchParams.set('embedMode', mode);
42
+ if (bookingSourceAttribution?.utmSource) url.searchParams.set('utm_source', bookingSourceAttribution.utmSource);
43
+ if (bookingSourceAttribution?.utmCampaign) url.searchParams.set('utm_campaign', bookingSourceAttribution.utmCampaign);
44
+ if (bookingSourceAttribution?.utmMedium) url.searchParams.set('utm_medium', bookingSourceAttribution.utmMedium);
45
+ if (bookingSourceAttribution?.partnerId) url.searchParams.set('partnerId', bookingSourceAttribution.partnerId);
46
+ if (bookingSourceAttribution?.agentId) url.searchParams.set('agentId', bookingSourceAttribution.agentId);
47
+ if (bookingSourceAttribution?.agentName) url.searchParams.set('agentName', bookingSourceAttribution.agentName);
48
+ return url.toString();
49
+ }, [bookingSourceAttribution, googleMapsApiKey, initialProductId, mode, showLanguageSelector]);
50
+
51
+ const frameStyle: CSSProperties = {
52
+ width: '100%',
53
+ minHeight: '900px',
54
+ border: 0,
55
+ display: 'block',
56
+ backgroundColor: 'transparent',
57
+ };
277
58
 
278
59
  return (
279
- <CompanyProvider companyId={companyId}>
280
- <BookingAppProvider mode={mode} permissions={permissions} googleMapsApiKey={googleMapsApiKey} onShowManage={onShowManage} getSuccessUrl={getSuccessUrl} showLanguageSelector={showLanguageSelector}>
281
- <div className={`min-h-screen overflow-x-hidden ${className}`} style={{ ...themeVars, background: `linear-gradient(to bottom, var(--booking-bg), var(--booking-bg-end))` }}>
282
- {showHeader && (
283
- <header className="py-4 w-full overflow-hidden" style={{ backgroundColor: 'var(--booking-header-bg)', color: 'var(--booking-header-text)' }}>
284
- <div className="max-w-4xl mx-auto px-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 min-w-0">
285
- <h1 className="text-xl font-semibold shrink-0" style={{ fontFamily: 'var(--booking-font-sans)' }}>Via Via Moraine Lake</h1>
286
- <div className="flex flex-wrap items-center gap-2 sm:gap-4 min-w-0">
287
- <CurrencySwitcher currency={currency} onCurrencyChange={setCurrency} />
288
- {showLanguageSelector && <LanguageSwitcher />}
289
- </div>
290
- </div>
291
- </header>
292
- )}
293
-
294
- {/* When embedded without header, show a compact toolbar */}
295
- {!showHeader && (
296
- <div className="border-b px-4 py-2 flex flex-wrap items-center justify-end gap-2 min-w-0" style={{ borderColor: 'var(--booking-border)', backgroundColor: 'var(--booking-surface)' }}>
297
- <CurrencySwitcher currency={currency} onCurrencyChange={setCurrency} />
298
- {showLanguageSelector && <LanguageSwitcher />}
299
- </div>
300
- )}
301
-
302
- <main className={`max-w-4xl mx-auto px-4 py-8 ${!showHeader ? 'pt-4' : ''}`} style={{ fontFamily: 'var(--booking-font-sans)' }}>
303
- <div className="overflow-visible p-8" style={{ backgroundColor: 'var(--booking-surface)', borderRadius: 'var(--booking-radius)', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }}>
304
- {loadingProducts ? (
305
- <div className="flex items-center justify-center py-16">
306
- <div style={{ color: 'var(--booking-text-muted)' }}>Loading experiences...</div>
307
- </div>
308
- ) : error ? (
309
- <div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--booking-error-bg)', borderWidth: '1px', borderStyle: 'solid', borderColor: 'var(--booking-error-border)', color: 'var(--booking-error-text)' }}>
310
- {error}
311
- </div>
312
- ) : navState.step === 'products' ? (
313
- <div>
314
- <h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--booking-text)' }}>
315
- Choose Your Experience
316
- </h2>
317
- <ProductList products={products} onSelect={handleSelectProduct} currency={currency} />
318
- </div>
319
- ) : navState.selectedProduct ? (
320
- <ErrorBoundary>
321
- {navState.selectedProduct.productType === 'PRIVATE_SHUTTLE' ? (
322
- <PrivateShuttleBookingFlow
323
- product={navState.selectedProduct}
324
- onBack={handleBack}
325
- currency={currency}
326
- onSuccess={handleBookingSuccess}
327
- initialBooking={isChangeMode ? initialBooking : undefined}
328
- onChangeBooking={isChangeMode ? onChangeBooking : undefined}
329
- />
330
- ) : (
331
- <BookingFlow
332
- product={navState.selectedProduct}
333
- onBack={handleBack}
334
- currency={currency}
335
- onSuccess={handleBookingSuccess}
336
- initialBooking={isChangeMode ? initialBooking : undefined}
337
- onChangeBooking={isChangeMode ? onChangeBooking : undefined}
338
- />
339
- )}
340
- </ErrorBoundary>
341
- ) : null}
342
- </div>
343
- </main>
344
- </div>
345
- </BookingAppProvider>
346
- </CompanyProvider>
60
+ <div className={className}>
61
+ <iframe title="Customer booking" src={src} style={frameStyle} loading="lazy" />
62
+ </div>
347
63
  );
348
64
  }
349
65
 
@@ -1,13 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useLayoutEffect } from 'react';
4
- import { formatBookingRefForDisplay } from '@/lib/booking-ref';
5
- import { BookingDetails, type BookingData } from '@/components/BookingDetails';
6
- import { type Currency } from '@/components/CurrencySwitcher';
7
- import { ENV } from '@/lib/env';
8
- import { formatCurrencyAmount } from '@/lib/currency';
9
-
10
- const API_URL = ENV.API_URL;
3
+ import { useMemo, type CSSProperties } from 'react';
11
4
 
12
5
  /** Provider-only fields merged after public manage lookup (e.g. embedded in provider dashboard). */
13
6
  export interface StaffBookingAttribution {
@@ -24,386 +17,59 @@ export interface StaffBookingAttribution {
24
17
  commissionUsesPartnerRate?: boolean;
25
18
  }
26
19
 
27
- function staffChannelLabel(source: string): string {
28
- const labels: Record<string, string> = {
29
- DASHBOARD: 'Dashboard',
30
- GYG: 'GetYourGuide',
31
- WEBSITE: 'Website (main site)',
32
- PARTNER_PORTAL: 'Website (main site)',
33
- WEBSITE_PARTNER_PORTAL: 'Partner booking portal',
34
- AFFILIATE: 'Affiliate',
35
- VIATOR: 'Viator',
36
- };
37
- return labels[source] ?? source;
38
- }
39
-
40
20
  export interface ManageBookingViewProps {
41
- /** Initial booking reference (ref or bookingReference from URL) */
42
21
  initialRef?: string;
43
- /** Initial last name (from URL or success callback) */
44
22
  initialLastName?: string;
45
- /** Reservation reference (e.g. after Stripe redirect; triggers poll until booking exists) */
46
23
  initialReservationRef?: string;
47
- /** True when returning from balance payment success (Stripe redirect); triggers polling to catch webhook update */
48
24
  initialPaymentSuccess?: boolean;
49
- /** When provided (e.g. manage page), called to replace URL when poll finds booking; when undefined (e.g. dialog), no navigation */
50
25
  replaceUrl?: (url: string) => void;
51
- /** When provided (e.g. dialog), show compact layout and optionally a close button. Set showCloseButton false when the host provides its own close (e.g. dialog X). */
52
26
  onClose?: () => void;
53
- /** When false, do not render the in-content close button (e.g. host dialog already has an X). Default true when onClose is set. */
54
27
  showCloseButton?: boolean;
55
- /**
56
- * When embedded with staff auth, fetch attribution (partner portal vs main site, partner/agent, commission)
57
- * after the public booking loads. Uses provider GET /1/bookings/:ref — implement in the host app.
58
- */
59
28
  fetchStaffAttribution?: (bookingReference: string) => Promise<StaffBookingAttribution | null>;
60
29
  }
61
30
 
31
+ const getBookingHost = (): string =>
32
+ process.env.NEXT_PUBLIC_BOOKING_WIDGET_URL || 'https://viaviamorainelake.com';
33
+
62
34
  export function ManageBookingView({
63
- initialRef: initialRefProp = '',
64
- initialLastName: initialLastNameProp = '',
65
- initialReservationRef: initialReservationRefProp = '',
35
+ initialRef = '',
36
+ initialLastName = '',
37
+ initialReservationRef = '',
66
38
  initialPaymentSuccess = false,
67
- replaceUrl,
68
39
  onClose,
69
40
  showCloseButton = true,
70
- fetchStaffAttribution,
71
41
  }: ManageBookingViewProps) {
72
- const [bookingReference, setBookingReference] = useState(initialRefProp);
73
- const [lastName, setLastName] = useState(initialLastNameProp);
74
- const [booking, setBooking] = useState<BookingData | null>(null);
75
- const [staffAttribution, setStaffAttribution] = useState<StaffBookingAttribution | null>(null);
76
- const [loading, setLoading] = useState(false);
77
- const [error, setError] = useState('');
78
-
79
- useLayoutEffect(() => {
80
- setBookingReference(initialRefProp ? formatBookingRefForDisplay(initialRefProp) || initialRefProp : initialRefProp);
81
- setLastName(initialLastNameProp);
82
- }, [initialRefProp, initialLastNameProp]);
83
-
84
- const initialRef = initialRefProp.trim();
85
- const initialLastName = initialLastNameProp.trim();
86
- const initialReservationRef = (initialReservationRefProp || '').trim();
87
-
88
- useEffect(() => {
89
- if (!booking?.bookingReference || !fetchStaffAttribution) {
90
- setStaffAttribution(null);
91
- return;
92
- }
93
- let cancelled = false;
94
- const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
95
- fetchStaffAttribution(ref)
96
- .then((a) => {
97
- if (!cancelled) setStaffAttribution(a);
98
- })
99
- .catch(() => {
100
- if (!cancelled) setStaffAttribution(null);
101
- });
102
- return () => {
103
- cancelled = true;
104
- };
105
- }, [booking?.bookingReference, fetchStaffAttribution]);
106
-
107
- // Auto-lookup when booking ref + lastName present (e.g. /manage?ref=ABC12345&lastName=Smith)
108
- // When initialPaymentSuccess, poll a few times if we get deposit-owing to catch webhook update
109
- useEffect(() => {
110
- if (!initialRef || !initialLastName) return;
111
- let cancelled = false;
112
- const POLL_DELAY_MS = 2000;
113
- const MAX_POLL_ATTEMPTS = 3;
114
-
115
- const fetchBooking = async (): Promise<BookingData | null> => {
116
- const response = await fetch(
117
- `${API_URL}/1/public/bookings/${encodeURIComponent(initialRef)}?lastName=${encodeURIComponent(initialLastName)}`,
118
- { method: 'GET', headers: { 'Content-Type': 'application/json' } }
119
- );
120
- if (!response.ok) {
121
- if (response.status === 404) throw new Error('Booking not found or last name does not match');
122
- const err = await response.json();
123
- throw new Error(err.errorMessage || err.error || 'Failed to lookup booking');
124
- }
125
- const data = await response.json();
126
- return data.data as BookingData;
127
- };
128
-
129
- const doLookup = async () => {
130
- setLoading(true);
131
- setError('');
132
- setBooking(null);
133
- try {
134
- let b = await fetchBooking();
135
- if (cancelled) return;
136
- setBooking(b);
137
-
138
- // If we just returned from payment and still show amount owing, poll to catch webhook
139
- const hasPaymentOwing = (booking: BookingData) => {
140
- const status = booking?.payment?.status;
141
- const balanceAmount = booking?.payment?.plan?.balanceAmount ?? 0;
142
- const depositAmount = booking?.payment?.plan?.depositAmount ?? 0;
143
- return (status === 'DEPOSIT_PAID' && balanceAmount > 0) ||
144
- (status === 'AWAITING_PAYMENT' && (depositAmount > 0 || balanceAmount > 0));
145
- };
146
-
147
- if (initialPaymentSuccess && b && hasPaymentOwing(b)) {
148
- for (let attempt = 1; attempt < MAX_POLL_ATTEMPTS && !cancelled; attempt++) {
149
- await new Promise((r) => setTimeout(r, POLL_DELAY_MS));
150
- if (cancelled) return;
151
- b = await fetchBooking();
152
- if (cancelled) return;
153
- setBooking(b);
154
- if (!b || !hasPaymentOwing(b)) break;
155
- }
156
- // Clean payment param from URL (whether we got FULLY_PAID or not)
157
- replaceUrl?.(`/manage?ref=${encodeURIComponent(initialRef)}&lastName=${encodeURIComponent(initialLastName)}`);
158
- }
159
- } catch (err) {
160
- if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to lookup booking');
161
- } finally {
162
- if (!cancelled) setLoading(false);
163
- }
164
- };
165
- doLookup();
166
- return () => { cancelled = true; };
167
- }, [initialRef, initialLastName, initialPaymentSuccess, replaceUrl]);
168
-
169
- // Auto-lookup when reservationRef + lastName present (e.g. Stripe redirect after payment)
170
- // Poll until webhook creates booking (up to 30s)
171
- useEffect(() => {
172
- if (!initialReservationRef || !initialLastName || initialRef) return;
173
- let cancelled = false;
174
- const POLL_MS = 1500;
175
- const MAX_MS = 30000;
176
- let attempt = 0;
177
- const doLookup = async () => {
178
- setLoading(true);
179
- setError('');
180
- setBooking(null);
181
- const poll = async (): Promise<void> => {
182
- if (cancelled) return;
183
- try {
184
- const response = await fetch(
185
- `${API_URL}/1/public/bookings/by-reservation?reservationRef=${encodeURIComponent(initialReservationRef)}&lastName=${encodeURIComponent(initialLastName)}`,
186
- { method: 'GET', headers: { 'Content-Type': 'application/json' } }
187
- );
188
- if (cancelled) return;
189
- if (response.ok) {
190
- const data = await response.json();
191
- const b = data.data as BookingData;
192
- if (b?.bookingReference && !cancelled) {
193
- const shortRef = formatBookingRefForDisplay(b.bookingReference);
194
- replaceUrl?.(`/manage?ref=${encodeURIComponent(shortRef)}&lastName=${encodeURIComponent(initialLastName)}`);
195
- setBookingReference(shortRef);
196
- setBooking(b);
197
- setLoading(false);
198
- return;
199
- }
200
- }
201
- } catch { /* ignore */ }
202
- if (cancelled) return;
203
- attempt += 1;
204
- if (attempt * POLL_MS < MAX_MS) {
205
- setTimeout(poll, POLL_MS);
206
- } else {
207
- if (!cancelled) setError('Booking not found. Enter your booking reference from the confirmation email when it arrives.');
208
- setLoading(false);
209
- }
210
- };
211
- await poll();
212
- };
213
- doLookup();
214
- return () => { cancelled = true; };
215
- }, [initialReservationRef, initialLastName, initialRef, replaceUrl]);
216
-
217
- async function handleLookup() {
218
- if (!bookingReference.trim() || !lastName.trim()) {
219
- setError('Please enter both booking reference and last name');
220
- return;
221
- }
222
-
223
- setLoading(true);
224
- setError('');
225
- setBooking(null);
226
-
227
- try {
228
- const response = await fetch(
229
- `${API_URL}/1/public/bookings/${encodeURIComponent(bookingReference.trim())}?lastName=${encodeURIComponent(lastName.trim())}`,
230
- { method: 'GET', headers: { 'Content-Type': 'application/json' } }
231
- );
232
-
233
- if (!response.ok) {
234
- if (response.status === 404) throw new Error('Booking not found or last name does not match');
235
- const err = await response.json();
236
- throw new Error(err.errorMessage || err.error || 'Failed to lookup booking');
237
- }
238
-
239
- const data = await response.json();
240
- setBooking(data.data);
241
- } catch (err) {
242
- setError(err instanceof Error ? err.message : 'Failed to lookup booking');
243
- } finally {
244
- setLoading(false);
245
- }
246
- }
247
-
248
- async function handleRefetch() {
249
- if (!bookingReference.trim() || !lastName.trim()) return;
250
- try {
251
- const response = await fetch(
252
- `${API_URL}/1/public/bookings/${encodeURIComponent(bookingReference.trim())}?lastName=${encodeURIComponent(lastName.trim())}`,
253
- { method: 'GET', headers: { 'Content-Type': 'application/json' } }
254
- );
255
- if (response.ok) {
256
- const data = await response.json();
257
- setBooking(data.data);
258
- }
259
- } catch {
260
- // Silently ignore refetch errors
261
- }
262
- }
263
-
264
- const isEmbed = !!onClose;
265
- const showClose = isEmbed && showCloseButton;
266
- const containerClass = isEmbed ? 'flex flex-col p-4' : 'min-h-screen bg-gradient-to-b from-stone-100 to-stone-200 flex flex-col items-center justify-center gap-4 p-4';
267
- const formOuterClass = isEmbed ? '' : 'min-h-screen bg-gradient-to-b from-stone-100 to-stone-200 flex items-center justify-center p-4';
268
-
269
- if (booking) {
270
- return (
271
- <div className={isEmbed ? 'w-full max-w-2xl' : 'min-h-screen bg-gradient-to-b from-stone-100 to-stone-200'}>
272
- {showClose && (
273
- <div className="flex justify-end mb-2">
274
- <button
275
- type="button"
276
- onClick={onClose}
277
- className="text-sm hover:opacity-80"
278
- style={{ color: 'var(--booking-text-muted)' }}
279
- >
280
- Close
281
- </button>
282
- </div>
283
- )}
284
- {staffAttribution ? (
285
- <div
286
- className="mb-4 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-800"
287
- role="region"
288
- aria-label="Staff booking attribution"
289
- >
290
- <p className="font-semibold text-stone-900 mb-2">Booking attribution</p>
291
- <p>
292
- <span className="text-stone-600">Channel:</span> {staffChannelLabel(staffAttribution.reportingSource)}
293
- </p>
294
- {staffAttribution.partnerId ? (
295
- <p>
296
- <span className="text-stone-600">Partner:</span>{' '}
297
- {staffAttribution.partnerName
298
- ? `${staffAttribution.partnerName} (${staffAttribution.partnerId})`
299
- : staffAttribution.partnerId}
300
- </p>
301
- ) : null}
302
- {staffAttribution.agentDisplay || staffAttribution.agentId ? (
303
- <p>
304
- <span className="text-stone-600">Agent:</span>{' '}
305
- {staffAttribution.agentDisplay ?? staffAttribution.agentId}
306
- </p>
307
- ) : null}
308
- {staffAttribution.commissionAmount != null && staffAttribution.commissionAmount > 0 ? (
309
- <p>
310
- <span className="text-stone-600">Commission:</span>{' '}
311
- {formatCurrencyAmount(staffAttribution.commissionAmount, staffAttribution.bookingCurrency as Currency)}
312
- {staffAttribution.commissionRate != null && staffAttribution.commissionRate > 0 ? (
313
- <>
314
- {' '}
315
- ({Math.round(staffAttribution.commissionRate * 100)}% of{' '}
316
- {staffAttribution.commissionUsesPartnerRate && staffAttribution.commissionBaseAmount != null
317
- ? `${formatCurrencyAmount(staffAttribution.commissionBaseAmount, staffAttribution.bookingCurrency as Currency)} pre-tax`
318
- : 'booking total'}
319
- )
320
- </>
321
- ) : null}
322
- </p>
323
- ) : null}
324
- </div>
325
- ) : null}
326
- <BookingDetails booking={booking} currency={booking.receipt.currency as Currency} onRefetch={handleRefetch} />
327
- </div>
328
- );
329
- }
330
-
331
- const waitingForBooking = initialReservationRef && initialLastName && !initialRef;
332
- if (waitingForBooking && loading) {
333
- return (
334
- <div className={containerClass}>
335
- <div className="w-10 h-10 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--booking-primary)' }} aria-hidden />
336
- <p style={{ color: 'var(--booking-text-muted)' }}>Confirming your booking…</p>
337
- </div>
338
- );
339
- }
42
+ const src = useMemo(() => {
43
+ const url = new URL('/manage', getBookingHost());
44
+ if (initialRef) url.searchParams.set('ref', initialRef);
45
+ if (initialLastName) url.searchParams.set('lastName', initialLastName);
46
+ if (initialReservationRef) url.searchParams.set('reservationRef', initialReservationRef);
47
+ if (initialPaymentSuccess) url.searchParams.set('payment', 'balance_success');
48
+ return url.toString();
49
+ }, [initialLastName, initialPaymentSuccess, initialRef, initialReservationRef]);
50
+
51
+ const frameStyle: CSSProperties = {
52
+ width: '100%',
53
+ minHeight: '700px',
54
+ border: 0,
55
+ display: 'block',
56
+ backgroundColor: 'transparent',
57
+ };
340
58
 
341
59
  return (
342
- <div className={formOuterClass || undefined}>
343
- <div className="p-8 max-w-md w-full shadow-xl" style={{ backgroundColor: 'var(--booking-surface)', borderRadius: 'var(--booking-radius)' }}>
344
- {isEmbed && (
345
- <div className={`flex justify-between items-center mb-4 ${showClose ? '' : 'justify-start'}`}>
346
- <h1 className="text-2xl font-bold" style={{ color: 'var(--booking-text)' }}>Manage Your Booking</h1>
347
- {showClose && (
348
- <button type="button" onClick={onClose} className="text-sm hover:opacity-80" style={{ color: 'var(--booking-text-muted)' }}>
349
- Close
350
- </button>
351
- )}
352
- </div>
353
- )}
354
- {!isEmbed && <h1 className="text-2xl font-bold mb-6" style={{ color: 'var(--booking-text)' }}>Manage Your Booking</h1>}
355
-
356
- <form onSubmit={(e) => { e.preventDefault(); handleLookup(); }} className="space-y-4">
357
- <div>
358
- <label htmlFor="manage-booking-ref" className="block text-sm font-medium mb-1" style={{ color: 'var(--booking-text)' }}>
359
- Booking Reference
360
- </label>
361
- <input
362
- id="manage-booking-ref"
363
- type="text"
364
- value={bookingReference}
365
- onChange={(e) => setBookingReference(formatBookingRefForDisplay(e.target.value) || e.target.value)}
366
- className="w-full px-4 py-2 rounded-lg focus:ring-2"
367
- style={{ borderColor: 'var(--booking-border-input)', borderWidth: '1px', borderStyle: 'solid' }}
368
- placeholder="e.g., ABC12345"
369
- required
370
- />
371
- </div>
372
- <div>
373
- <label htmlFor="manage-booking-lastname" className="block text-sm font-medium mb-1" style={{ color: 'var(--booking-text)' }}>
374
- Last Name
375
- </label>
376
- <input
377
- id="manage-booking-lastname"
378
- type="text"
379
- value={lastName}
380
- onChange={(e) => setLastName(e.target.value)}
381
- className="w-full px-4 py-2 rounded-lg focus:ring-2"
382
- style={{ borderColor: 'var(--booking-border-input)', borderWidth: '1px', borderStyle: 'solid' }}
383
- placeholder="As entered at booking"
384
- required
385
- />
386
- </div>
387
- {error && (
388
- <div className="rounded-lg p-3 text-sm" style={{ backgroundColor: 'var(--booking-error-bg)', border: '1px solid var(--booking-error-border)', color: 'var(--booking-error-text)' }}>{error}</div>
389
- )}
60
+ <div className="w-full">
61
+ {onClose && showCloseButton ? (
62
+ <div className="mb-2 flex justify-end">
390
63
  <button
391
- type="submit"
392
- disabled={loading}
393
- className="w-full py-3 text-white font-semibold rounded-lg transition-colors disabled:cursor-not-allowed"
394
- style={{ backgroundColor: loading ? 'var(--booking-border-input)' : 'var(--booking-primary)' }}
64
+ type="button"
65
+ onClick={onClose}
66
+ className="rounded border border-stone-300 px-3 py-1 text-sm hover:bg-stone-100"
395
67
  >
396
- {loading ? 'Looking up...' : 'View Booking'}
68
+ Close
397
69
  </button>
398
- </form>
399
-
400
- {!isEmbed && (
401
- <div className="mt-6 pt-6 text-center text-sm" style={{ borderColor: 'var(--booking-border)', borderTopWidth: '1px', borderTopStyle: 'solid', color: 'var(--booking-text-muted)' }}>
402
- <p>Enter your booking reference and last name to view your booking details.</p>
403
- <p className="mt-1" style={{ color: 'var(--booking-text-muted)' }}>You can also share a link: /manage?ref=ABC12345&amp;lastName=Smith</p>
404
- </div>
405
- )}
406
- </div>
70
+ </div>
71
+ ) : null}
72
+ <iframe title="Manage booking" src={src} style={frameStyle} loading="lazy" />
407
73
  </div>
408
74
  );
409
75
  }