@ticketboothapp/booking 0.1.9 → 0.1.11

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