@ticketboothapp/booking 0.1.3 → 0.1.7

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.
Files changed (45) hide show
  1. package/package.json +21 -1
  2. package/src/components/BookingDetails.tsx +546 -0
  3. package/src/components/BookingFlow.tsx +2952 -0
  4. package/src/components/BookingWidget.tsx +349 -0
  5. package/src/components/Calendar.tsx +906 -0
  6. package/src/components/CheckoutModal.tsx +294 -0
  7. package/src/components/CurrencySwitcher.tsx +81 -0
  8. package/src/components/ErrorBoundary.tsx +63 -0
  9. package/src/components/ItineraryBuilder.tsx +83 -0
  10. package/src/components/LanguageSwitcher.tsx +30 -0
  11. package/src/components/ManageBookingView.tsx +409 -0
  12. package/src/components/MealDrinkAddOnSelector.tsx +330 -0
  13. package/src/components/PickupLocationSelector.tsx +1541 -0
  14. package/src/components/PriceBreakdown.tsx +154 -0
  15. package/src/components/PriceSummary.tsx +211 -0
  16. package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
  17. package/src/components/ProductList.tsx +78 -0
  18. package/src/components/TermsAcceptance.tsx +110 -0
  19. package/src/components/WhatsAppPhoneInput.tsx +224 -0
  20. package/src/components/index.ts +31 -0
  21. package/src/contexts/CompanyContext.tsx +8 -20
  22. package/src/index.ts +16 -0
  23. package/src/lib/api.ts +801 -0
  24. package/src/lib/booking-ref.ts +13 -0
  25. package/src/lib/checkout-breakdown.test.ts +70 -0
  26. package/src/lib/checkout-breakdown.ts +69 -0
  27. package/src/lib/constants.ts +17 -0
  28. package/src/lib/currency.ts +88 -0
  29. package/src/lib/env.ts +10 -12
  30. package/src/lib/i18n/config.ts +21 -0
  31. package/src/lib/i18n/index.tsx +144 -0
  32. package/src/lib/i18n/messages/en.json +192 -0
  33. package/src/lib/i18n/messages/fr.json +192 -0
  34. package/src/lib/itinerary-labels.ts +70 -0
  35. package/src/lib/location-calculations.ts +43 -0
  36. package/src/lib/location-utils.ts +139 -0
  37. package/src/lib/map-utils.ts +153 -0
  38. package/src/lib/marker-icons.ts +113 -0
  39. package/src/lib/pickup-location-types.ts +25 -0
  40. package/src/lib/places-api.ts +154 -0
  41. package/src/lib/pricing.ts +466 -0
  42. package/src/lib/theme.ts +83 -0
  43. package/src/lib/utils.ts +9 -0
  44. package/src/types/google-maps.d.ts +2 -0
  45. package/tsconfig.json +8 -2
@@ -0,0 +1,349 @@
1
+ 'use client';
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 ============
25
+
26
+ export interface BookingWidgetProps {
27
+ /**
28
+ * Optional product ID to start with a specific product selected
29
+ */
30
+ 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
+ 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
+ onBookingSuccess?: (data: { reservationReference: string; sessionId?: string }) => void;
57
+
58
+ /**
59
+ * Callback when user navigates back
60
+ */
61
+ onBack?: () => void;
62
+
63
+ /**
64
+ * Custom className for the container
65
+ */
66
+ 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
+ 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
+ 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
+ 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
+ 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
+ */
101
+ 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>;
157
+ }
158
+
159
+ // ============ Booking Widget Component ============
160
+
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
+ export function BookingWidget({
173
+ 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
+ className = '',
186
+ mode = 'standalone',
187
+ permissions = {},
188
+ googleMapsApiKey,
189
+ onShowManage,
190
+ getSuccessUrl,
191
+ showLanguageSelector = true,
192
+ theme: themeOverride,
193
+ }: 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]);
277
+
278
+ 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>
347
+ );
348
+ }
349
+