@ticketboothapp/booking 0.1.4 → 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.
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 +7 -5
  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 +4 -2
  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 +5 -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,78 @@
1
+ 'use client';
2
+
3
+ import type { Product } from '@/lib/api';
4
+ import { useTranslations, useLocale } from '@/lib/i18n';
5
+ import type { Currency } from '@/components/CurrencySwitcher';
6
+ import { formatCurrencyAmount } from '@/lib/currency';
7
+
8
+ /** Lowest base price in display currency from ticketbooth-product-prices (minPriceByCurrency). No conversion; CAD fallback from option pricing only. */
9
+ function getProductFromPrice(product: Product, currency: Currency): number | undefined {
10
+ const fromApi = product.minPriceByCurrency?.[currency];
11
+ if (fromApi != null) return fromApi;
12
+ if (currency !== 'CAD') return undefined;
13
+ const activeOptions = product.options?.filter((opt) => opt.status === 'ACTIVE') || [];
14
+ const adultPricesCAD = activeOptions
15
+ .map((opt) => opt.pricing?.ADULT)
16
+ .filter((price): price is number => price != null);
17
+ return adultPricesCAD.length > 0 ? Math.min(...adultPricesCAD) : undefined;
18
+ }
19
+
20
+ interface ProductListProps {
21
+ products: Product[];
22
+ onSelect: (product: Product) => void;
23
+ currency: Currency;
24
+ }
25
+
26
+ export function ProductList({ products, onSelect, currency }: ProductListProps) {
27
+ const { t } = useTranslations();
28
+ const { locale } = useLocale();
29
+
30
+ return (
31
+ <div className="space-y-4">
32
+ {products.map((product) => {
33
+ const fromPrice = getProductFromPrice(product, currency);
34
+
35
+ return (
36
+ <button
37
+ key={product.productId}
38
+ onClick={() => onSelect(product)}
39
+ className="w-full text-left p-6 bg-white rounded-xl border border-stone-200 hover:border-emerald-400 hover:shadow-lg transition-all group"
40
+ >
41
+ <div className="flex items-start justify-between">
42
+ <div className="flex-1">
43
+ <div className="flex items-center gap-2">
44
+ <h3 className="text-lg font-semibold text-stone-900 group-hover:text-emerald-700 transition-colors">
45
+ {product.name}
46
+ </h3>
47
+ {product.mostPopular && (
48
+ <span className="inline-flex items-center rounded-full bg-[#ff4d00] text-white text-[10px] font-semibold px-1.5 py-0.5">
49
+ {t('booking.mostPopular') ?? 'Most popular'}
50
+ </span>
51
+ )}
52
+ </div>
53
+ {product.description && (
54
+ <p className="mt-1 text-sm text-stone-500 line-clamp-2">
55
+ {product.description}
56
+ </p>
57
+ )}
58
+ {fromPrice != null && (
59
+ <p className="mt-3 text-sm font-medium text-emerald-600">
60
+ {t('products.from')} {formatCurrencyAmount(fromPrice, currency, locale)}
61
+ </p>
62
+ )}
63
+ </div>
64
+ <div className="ml-4 text-stone-400 group-hover:text-emerald-600 transition-colors">
65
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
66
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
67
+ </svg>
68
+ </div>
69
+ </div>
70
+ </button>
71
+ );
72
+ })}
73
+ </div>
74
+ );
75
+ }
76
+
77
+
78
+
@@ -0,0 +1,110 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ export interface TermsAcceptanceProps {
6
+ checked: boolean;
7
+ onChange: (checked: boolean) => void;
8
+ /** Translation function for labels */
9
+ t: (key: string) => string;
10
+ /** Optional custom terms content (HTML or plain text). If not provided, uses t('terms.content'). */
11
+ termsContent?: string;
12
+ }
13
+
14
+ /**
15
+ * Checkbox to accept terms & conditions, with a "View terms" link that opens
16
+ * a small modal showing the full text (in-page, not a separate page).
17
+ * Least intrusive: user can proceed by checking the box; they can open the
18
+ * modal to read the full terms if they want.
19
+ */
20
+ export function TermsAcceptance({
21
+ checked,
22
+ onChange,
23
+ t,
24
+ termsContent,
25
+ }: TermsAcceptanceProps) {
26
+ const [modalOpen, setModalOpen] = useState(false);
27
+ const content = termsContent ?? t('terms.content');
28
+
29
+ return (
30
+ <>
31
+ <label className="flex items-start gap-3 cursor-pointer group">
32
+ <input
33
+ type="checkbox"
34
+ checked={checked}
35
+ onChange={(e) => onChange(e.target.checked)}
36
+ className="mt-1 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
37
+ aria-describedby="terms-view-link"
38
+ />
39
+ <span className="text-sm text-stone-700">
40
+ {t('terms.acceptPrefix')}{' '}
41
+ <button
42
+ type="button"
43
+ id="terms-view-link"
44
+ onClick={(e) => {
45
+ e.preventDefault();
46
+ setModalOpen(true);
47
+ }}
48
+ className="text-emerald-600 hover:text-emerald-700 underline focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-1 rounded"
49
+ >
50
+ {t('terms.viewTerms')}
51
+ </button>
52
+ </span>
53
+ </label>
54
+
55
+ {/* Modal: small overlay with scrollable terms */}
56
+ {modalOpen && (
57
+ <div
58
+ className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40"
59
+ role="dialog"
60
+ aria-modal="true"
61
+ aria-labelledby="terms-modal-title"
62
+ onClick={() => setModalOpen(false)}
63
+ >
64
+ <div
65
+ className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col"
66
+ onClick={(e) => e.stopPropagation()}
67
+ >
68
+ <div className="flex items-center justify-between px-5 py-4 border-b border-stone-200 shrink-0">
69
+ <h2 id="terms-modal-title" className="text-lg font-semibold text-stone-900">
70
+ {t('terms.title')}
71
+ </h2>
72
+ <button
73
+ type="button"
74
+ onClick={() => setModalOpen(false)}
75
+ className="p-2 rounded-lg text-stone-500 hover:text-stone-700 hover:bg-stone-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
76
+ aria-label={t('common.close')}
77
+ >
78
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
79
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
80
+ </svg>
81
+ </button>
82
+ </div>
83
+ <div className="flex-1 overflow-y-auto px-5 py-4 text-sm text-stone-600 whitespace-pre-wrap">
84
+ {content}
85
+ </div>
86
+ <div className="px-5 py-4 border-t border-stone-200 shrink-0 flex gap-3 justify-end">
87
+ <button
88
+ type="button"
89
+ onClick={() => setModalOpen(false)}
90
+ className="px-4 py-2 text-sm font-medium text-stone-700 bg-stone-100 rounded-lg hover:bg-stone-200"
91
+ >
92
+ {t('common.close')}
93
+ </button>
94
+ <button
95
+ type="button"
96
+ onClick={() => {
97
+ onChange(true);
98
+ setModalOpen(false);
99
+ }}
100
+ className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700"
101
+ >
102
+ {t('terms.acceptAndClose')}
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ )}
108
+ </>
109
+ );
110
+ }
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState, useEffect, useRef } from 'react';
4
+
5
+ const COUNTRY_OPTIONS: { code: string; dial: string; flag: string }[] = [
6
+ { code: 'CA', dial: '+1', flag: '🇨🇦' },
7
+ { code: 'US', dial: '+1', flag: '🇺🇸' },
8
+ { code: 'GB', dial: '+44', flag: '🇬🇧' },
9
+ { code: 'AU', dial: '+61', flag: '🇦🇺' },
10
+ { code: 'DE', dial: '+49', flag: '🇩🇪' },
11
+ { code: 'FR', dial: '+33', flag: '🇫🇷' },
12
+ { code: 'ES', dial: '+34', flag: '🇪🇸' },
13
+ { code: 'IT', dial: '+39', flag: '🇮🇹' },
14
+ { code: 'MX', dial: '+52', flag: '🇲🇽' },
15
+ { code: 'IN', dial: '+91', flag: '🇮🇳' },
16
+ { code: 'JP', dial: '+81', flag: '🇯🇵' },
17
+ { code: 'BR', dial: '+55', flag: '🇧🇷' },
18
+ { code: 'NL', dial: '+31', flag: '🇳🇱' },
19
+ { code: 'IE', dial: '+353', flag: '🇮🇪' },
20
+ { code: 'NZ', dial: '+64', flag: '🇳🇿' },
21
+ { code: 'ZA', dial: '+27', flag: '🇿🇦' },
22
+ { code: 'SG', dial: '+65', flag: '🇸🇬' },
23
+ { code: 'HK', dial: '+852', flag: '🇭🇰' },
24
+ { code: 'PH', dial: '+63', flag: '🇵🇭' },
25
+ { code: 'PL', dial: '+48', flag: '🇵🇱' },
26
+ { code: 'SE', dial: '+46', flag: '🇸🇪' },
27
+ { code: 'CH', dial: '+41', flag: '🇨🇭' },
28
+ { code: 'AT', dial: '+43', flag: '🇦🇹' },
29
+ { code: 'BE', dial: '+32', flag: '🇧🇪' },
30
+ { code: 'PT', dial: '+351', flag: '🇵🇹' },
31
+ { code: 'GR', dial: '+30', flag: '🇬🇷' },
32
+ { code: 'CZ', dial: '+420', flag: '🇨🇿' },
33
+ { code: 'RO', dial: '+40', flag: '🇷🇴' },
34
+ { code: 'HU', dial: '+36', flag: '🇭🇺' },
35
+ { code: 'TR', dial: '+90', flag: '🇹🇷' },
36
+ { code: 'IL', dial: '+972', flag: '🇮🇱' },
37
+ { code: 'AE', dial: '+971', flag: '🇦🇪' },
38
+ { code: 'SA', dial: '+966', flag: '🇸🇦' },
39
+ { code: 'CN', dial: '+86', flag: '🇨🇳' },
40
+ { code: 'KR', dial: '+82', flag: '🇰🇷' },
41
+ { code: 'TH', dial: '+66', flag: '🇹🇭' },
42
+ { code: 'MY', dial: '+60', flag: '🇲🇾' },
43
+ { code: 'ID', dial: '+62', flag: '🇮🇩' },
44
+ { code: 'VN', dial: '+84', flag: '🇻🇳' },
45
+ { code: 'AR', dial: '+54', flag: '🇦🇷' },
46
+ { code: 'CL', dial: '+56', flag: '🇨🇱' },
47
+ { code: 'CO', dial: '+57', flag: '🇨🇴' },
48
+ { code: 'PE', dial: '+51', flag: '🇵🇪' },
49
+ { code: 'EG', dial: '+20', flag: '🇪🇬' },
50
+ { code: 'NG', dial: '+234', flag: '🇳🇬' },
51
+ { code: 'KE', dial: '+254', flag: '🇰🇪' },
52
+ { code: 'RU', dial: '+7', flag: '🇷🇺' },
53
+ { code: 'UA', dial: '+380', flag: '🇺🇦' },
54
+ ];
55
+
56
+ const DIAL_TO_COUNTRIES = COUNTRY_OPTIONS.reduce<Record<string, string[]>>((acc, c) => {
57
+ if (!acc[c.dial]) acc[c.dial] = [];
58
+ acc[c.dial].push(c.code);
59
+ return acc;
60
+ }, {});
61
+
62
+ function parsePhoneValue(value: string): { dial: string; local: string; countryCode: string | null } {
63
+ if (!value || typeof value !== 'string') return { dial: '+1', local: '', countryCode: null };
64
+ const trimmed = value.trim();
65
+
66
+ if (trimmed.match(/^\+\d+$/)) {
67
+ const dial = trimmed;
68
+ const codes = DIAL_TO_COUNTRIES[dial];
69
+ return { dial, local: '', countryCode: codes?.length === 1 ? codes[0]! : null };
70
+ }
71
+
72
+ const digitsOnly = trimmed.replace(/\D/g, '');
73
+ if (!digitsOnly) return { dial: '+1', local: '', countryCode: null };
74
+
75
+ const sortedByDialLength = [...COUNTRY_OPTIONS].sort((a, b) => {
76
+ const aDigits = a.dial.replace(/\D/g, '').length;
77
+ const bDigits = b.dial.replace(/\D/g, '').length;
78
+ return bDigits - aDigits;
79
+ });
80
+ const dialDigits = (d: string) => d.replace(/\D/g, '');
81
+ for (const c of sortedByDialLength) {
82
+ const d = dialDigits(c.dial);
83
+ if (digitsOnly.startsWith(d)) {
84
+ const codes = DIAL_TO_COUNTRIES[c.dial];
85
+ return {
86
+ dial: c.dial,
87
+ local: digitsOnly.slice(d.length),
88
+ countryCode: codes?.length === 1 ? codes[0]! : null,
89
+ };
90
+ }
91
+ }
92
+ return { dial: '+1', local: digitsOnly, countryCode: null };
93
+ }
94
+
95
+ function formatLocal(local: string, dial: string): string {
96
+ const d = local.replace(/\D/g, '');
97
+ if (!d) return '';
98
+
99
+ if (dial === '+1') {
100
+ if (d.length <= 3) return d;
101
+ if (d.length <= 6) return `(${d.slice(0, 3)}) ${d.slice(3)}`;
102
+ return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6, 10)}`;
103
+ }
104
+
105
+ const parts: string[] = [];
106
+ for (let i = 0; i < d.length; i += 3) {
107
+ parts.push(d.slice(i, i + 3));
108
+ }
109
+ return parts.join(' ').trim();
110
+ }
111
+
112
+ export interface WhatsAppPhoneInputProps {
113
+ value: string;
114
+ onChange: (value: string) => void;
115
+ placeholder?: string;
116
+ label?: string;
117
+ optional?: boolean;
118
+ error?: string;
119
+ 'data-testid'?: string;
120
+ }
121
+
122
+ export function WhatsAppPhoneInput({
123
+ value,
124
+ onChange,
125
+ placeholder = '(555) 123-4567',
126
+ label = 'WhatsApp Number',
127
+ optional = true,
128
+ error,
129
+ 'data-testid': dataTestId,
130
+ }: WhatsAppPhoneInputProps) {
131
+ const parsed = useMemo(() => parsePhoneValue(value), [value]);
132
+ const { local, countryCode: derivedCountry } = parsed;
133
+
134
+ const [selectedCountryCode, setSelectedCountryCode] = useState<string>(() => {
135
+ if (derivedCountry) return derivedCountry;
136
+ return 'CA';
137
+ });
138
+
139
+ const [localDigits, setLocalDigits] = useState(() => local);
140
+ const lastEmitted = useRef(value);
141
+ const justEmitted = useRef(false);
142
+
143
+ useEffect(() => {
144
+ if (derivedCountry) setSelectedCountryCode(derivedCountry);
145
+ }, [derivedCountry]);
146
+
147
+ useEffect(() => {
148
+ if (justEmitted.current) {
149
+ justEmitted.current = false;
150
+ return;
151
+ }
152
+ if (value !== lastEmitted.current) {
153
+ lastEmitted.current = value;
154
+ setLocalDigits(local);
155
+ }
156
+ }, [value, local]);
157
+
158
+ const selected = COUNTRY_OPTIONS.find((c) => c.code === selectedCountryCode) ?? COUNTRY_OPTIONS[0];
159
+
160
+ const handleCountryChange = (code: string) => {
161
+ setSelectedCountryCode(code);
162
+ const c = COUNTRY_OPTIONS.find((x) => x.code === code) ?? COUNTRY_OPTIONS[0];
163
+ const digits = localDigits.replace(/\D/g, '').slice(0, 15);
164
+ const full = `${c.dial}${digits}`;
165
+ lastEmitted.current = full;
166
+ justEmitted.current = true;
167
+ onChange(full);
168
+ };
169
+
170
+ const handleLocalChange = (input: string) => {
171
+ const digits = input.replace(/\D/g, '').slice(0, 15);
172
+ setLocalDigits(digits);
173
+ const full = `${selected.dial}${digits}`;
174
+ lastEmitted.current = full;
175
+ justEmitted.current = true;
176
+ onChange(full);
177
+ };
178
+
179
+ const displayLocal = formatLocal(localDigits || local, selected.dial);
180
+
181
+ return (
182
+ <div className="space-y-1">
183
+ {label && (
184
+ <label className="block text-sm font-medium text-stone-700 mb-2">
185
+ {label}
186
+ {optional && <span className="text-stone-400 text-xs font-normal ml-1">(optional)</span>}
187
+ </label>
188
+ )}
189
+ <div
190
+ data-testid={dataTestId}
191
+ className={`flex rounded-lg border bg-white overflow-hidden transition-shadow ${
192
+ error
193
+ ? 'border-red-400 focus-within:ring-2 focus-within:ring-red-500 focus-within:border-red-500'
194
+ : 'border-stone-300 focus-within:ring-2 focus-within:ring-emerald-500 focus-within:border-emerald-500'
195
+ }`}
196
+ >
197
+ <select
198
+ value={selected.code}
199
+ onChange={(e) => handleCountryChange(e.target.value)}
200
+ className="w-20 min-w-0 shrink-0 pl-2 pr-6 py-3 bg-stone-50 border-r border-stone-200 text-stone-900 text-sm font-medium focus:outline-none cursor-pointer appearance-none bg-[length:10px] bg-[right_4px_center] bg-no-repeat"
201
+ style={{
202
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23787571'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
203
+ }}
204
+ >
205
+ {COUNTRY_OPTIONS.map((c) => (
206
+ <option key={c.code} value={c.code}>
207
+ {c.flag} {c.dial}
208
+ </option>
209
+ ))}
210
+ </select>
211
+ <input
212
+ type="tel"
213
+ inputMode="numeric"
214
+ autoComplete="tel-national"
215
+ value={displayLocal}
216
+ onChange={(e) => handleLocalChange(e.target.value)}
217
+ placeholder={placeholder}
218
+ className="flex-1 min-w-0 py-3 px-4 text-stone-900 placeholder-stone-400 focus:outline-none text-sm"
219
+ />
220
+ </div>
221
+ {error && <p className="text-xs text-red-600 mt-1">{error}</p>}
222
+ </div>
223
+ );
224
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Booking Widget Components
3
+ *
4
+ * Exports embeddable components for use in other React applications
5
+ */
6
+
7
+ export { BookingWidget } from './BookingWidget';
8
+ export type { BookingWidgetProps } from './BookingWidget';
9
+ export type { BookingAppMode, BookingAppPermissions, ViewerRole, ManageParams } from '../contexts/BookingAppContext';
10
+ export { ManageBookingView } from './ManageBookingView';
11
+ export type { ManageBookingViewProps, StaffBookingAttribution } from './ManageBookingView';
12
+
13
+ export { BookingFlow } from './BookingFlow';
14
+ export { Calendar } from './Calendar';
15
+ export type { DateAvailability } from './Calendar';
16
+ export { PickupLocationSelector } from './PickupLocationSelector';
17
+ export type { CustomPickupLocation } from './PickupLocationSelector';
18
+ export { BookingDetails } from './BookingDetails';
19
+ export { PriceBreakdown } from './PriceBreakdown';
20
+ export type { PriceBreakdownProps } from './PriceBreakdown';
21
+ export { ProductList } from './ProductList';
22
+ export { CurrencySwitcher, useCurrency } from './CurrencySwitcher';
23
+ export type { Currency } from './CurrencySwitcher';
24
+
25
+ // Re-export types from API for convenience
26
+ export type { Product, Availability } from '@/lib/api';
27
+
28
+ // Theme: host can pass partial theme to customize colours, fonts, etc.
29
+ export type { BookingTheme } from '../lib/theme';
30
+ export { DEFAULT_BOOKING_THEME, mergeTheme } from '../lib/theme';
31
+
@@ -1,14 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
4
- import { ENV } from '../lib/env';
5
-
6
- export interface Company {
7
- settings?: {
8
- timezone?: string;
9
- };
10
- [key: string]: unknown;
11
- }
4
+ import { getCompany, type Company } from '@/lib/api';
5
+ import { ENV } from '@/lib/env';
12
6
 
13
7
  interface CompanyContextType {
14
8
  company: Company | null;
@@ -20,26 +14,20 @@ const CompanyContext = createContext<CompanyContextType | undefined>(undefined);
20
14
 
21
15
  interface CompanyProviderProps {
22
16
  children: ReactNode;
17
+ /** Optional company ID. When provided (e.g. from provider dashboard), use instead of ENV.COMPANY_ID. */
18
+ companyId?: string;
23
19
  }
24
20
 
25
- async function getCompany(companyId: string): Promise<Company | null> {
26
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
27
- if (ENV.BASIC_AUTH) headers.Authorization = `Basic ${ENV.BASIC_AUTH}`;
28
- const response = await fetch(`${ENV.API_URL}/1/companies/${companyId}`, { method: 'GET', headers });
29
- if (!response.ok) throw new Error(`Failed to load company (${response.status})`);
30
- const data = await response.json();
31
- return data?.data?.company ?? null;
32
- }
33
-
34
- export function CompanyProvider({ children }: CompanyProviderProps) {
21
+ export function CompanyProvider({ children, companyId: companyIdProp }: CompanyProviderProps) {
35
22
  const [company, setCompany] = useState<Company | null>(null);
36
23
  const [loading, setLoading] = useState(true);
37
24
  const [error, setError] = useState<string | null>(null);
25
+ const companyId = companyIdProp ?? ENV.COMPANY_ID;
38
26
 
39
27
  useEffect(() => {
40
28
  async function fetchCompany() {
41
29
  try {
42
- const companyData = await getCompany(ENV.COMPANY_ID);
30
+ const companyData = await getCompany(companyId);
43
31
  setCompany(companyData);
44
32
  setError(null);
45
33
  } catch (err) {
@@ -50,7 +38,7 @@ export function CompanyProvider({ children }: CompanyProviderProps) {
50
38
  }
51
39
  }
52
40
  fetchCompany();
53
- }, []);
41
+ }, [companyId]);
54
42
 
55
43
  return <CompanyContext.Provider value={{ company, loading, error }}>{children}</CompanyContext.Provider>;
56
44
  }
package/src/index.ts CHANGED
@@ -89,3 +89,8 @@ export {
89
89
  type ManageBookingViewProps,
90
90
  type StaffBookingAttribution,
91
91
  } from './components/ManageBookingView';
92
+
93
+ export type {
94
+ Product,
95
+ Availability,
96
+ } from './lib/api';