@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.
- package/package.json +21 -1
- package/src/components/BookingDetails.tsx +546 -0
- package/src/components/BookingFlow.tsx +2952 -0
- package/src/components/BookingWidget.tsx +7 -5
- package/src/components/Calendar.tsx +906 -0
- package/src/components/CheckoutModal.tsx +294 -0
- package/src/components/CurrencySwitcher.tsx +81 -0
- package/src/components/ErrorBoundary.tsx +63 -0
- package/src/components/ItineraryBuilder.tsx +83 -0
- package/src/components/LanguageSwitcher.tsx +30 -0
- package/src/components/ManageBookingView.tsx +4 -2
- package/src/components/MealDrinkAddOnSelector.tsx +330 -0
- package/src/components/PickupLocationSelector.tsx +1541 -0
- package/src/components/PriceBreakdown.tsx +154 -0
- package/src/components/PriceSummary.tsx +211 -0
- package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
- package/src/components/ProductList.tsx +78 -0
- package/src/components/TermsAcceptance.tsx +110 -0
- package/src/components/WhatsAppPhoneInput.tsx +224 -0
- package/src/components/index.ts +31 -0
- package/src/contexts/CompanyContext.tsx +8 -20
- package/src/index.ts +5 -0
- package/src/lib/api.ts +801 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/checkout-breakdown.test.ts +70 -0
- package/src/lib/checkout-breakdown.ts +69 -0
- package/src/lib/constants.ts +17 -0
- package/src/lib/currency.ts +88 -0
- package/src/lib/env.ts +10 -12
- package/src/lib/i18n/config.ts +21 -0
- package/src/lib/i18n/index.tsx +144 -0
- package/src/lib/i18n/messages/en.json +192 -0
- package/src/lib/i18n/messages/fr.json +192 -0
- package/src/lib/itinerary-labels.ts +70 -0
- package/src/lib/location-calculations.ts +43 -0
- package/src/lib/location-utils.ts +139 -0
- package/src/lib/map-utils.ts +153 -0
- package/src/lib/marker-icons.ts +113 -0
- package/src/lib/pickup-location-types.ts +25 -0
- package/src/lib/places-api.ts +154 -0
- package/src/lib/pricing.ts +466 -0
- package/src/lib/theme.ts +83 -0
- package/src/lib/utils.ts +9 -0
- package/src/types/google-maps.d.ts +2 -0
- 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 {
|
|
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
|
-
|
|
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(
|
|
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
|
}
|