@nuitee/booking-widget 1.0.1 → 1.0.3

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.
@@ -197,6 +197,7 @@ function deriveRatePolicyDetail(avRate) {
197
197
  * @param {string} [config.s3BaseUrl] - Base URL for room images (e.g. from VITE_AWS_S3_PATH).
198
198
  * @param {string|number} [config.propertyId] - Property ID (legacy). Prefer propertyKey for proxy endpoints.
199
199
  * @param {string} [config.propertyKey] - Property key for proxy endpoints (e.g. VITE_PROPERTY_KEY). Used in /proxy/availability and /proxy/ari-properties?key=.
200
+ * @param {string} [config.mode] - 'sandbox' to pass sandbox=true on all proxy/API calls; omit or 'live' to not pass it.
200
201
  * @param {string} [config.currency] - Currency code (e.g. 'MAD'). Default 'MAD'.
201
202
  * @param {Object} [config.headers] - Optional. Static headers for each request (e.g. { 'X-API-Key': '...' }).
202
203
  * @param {function(): Object} [config.getHeaders] - Optional. Return headers for each request (merged after headers). Use for dynamic headers.
@@ -215,6 +216,7 @@ function createBookingApi(config = {}) {
215
216
  if (rawKey && !propertyKey && typeof console !== 'undefined' && console.warn) {
216
217
  console.warn('[booking-api] Property key must start with "book_engine_". Ignoring invalid value. Set VITE_PROPERTY_KEY in your .env (see .env.example).');
217
218
  }
219
+ const sandbox = config.mode === 'sandbox';
218
220
  const currency = config.currency || 'MAD';
219
221
  const staticHeaders = config.headers || {};
220
222
  const getHeaders = config.getHeaders || (() => ({}));
@@ -279,8 +281,11 @@ function createBookingApi(config = {}) {
279
281
  // Fetch get_properties first to get currency_code (e.g. 'EUR') and room details
280
282
  let propertyRooms = {};
281
283
  let propertyCurrency = currency;
284
+ const propQuery = propertyKey
285
+ ? `key=${encodeURIComponent(propertyKey)}${sandbox ? '&sandbox=true' : ''}`
286
+ : '';
282
287
  const propFullUrl = propertyKey
283
- ? `${availabilityBaseUrl}/proxy/ari-properties?key=${encodeURIComponent(propertyKey)}`
288
+ ? `${availabilityBaseUrl}/proxy/ari-properties?${propQuery}`
284
289
  : propertyBaseUrl
285
290
  ? `${propertyBaseUrl}/ari/get_properties?id=${encodeURIComponent(propertyId)}`
286
291
  : `/ari/get_properties?id=${encodeURIComponent(propertyId)}`;
@@ -307,7 +312,7 @@ function createBookingApi(config = {}) {
307
312
  };
308
313
 
309
314
  const availabilityPath = propertyKey
310
- ? `/proxy/availability?key=${encodeURIComponent(propertyKey)}`
315
+ ? `/proxy/availability?key=${encodeURIComponent(propertyKey)}${sandbox ? '&sandbox=true' : ''}`
311
316
  : `/api/calendar/booking_engine/${propertyId}/availability`;
312
317
  const data = await request('POST', availabilityPath, body, availabilityBaseUrl);
313
318
  if (data && typeof data === 'object' && (data.error || data.message)) {
@@ -520,14 +525,15 @@ function computeCheckoutTotal(state, nights) {
520
525
  * Use this when integrating Stripe (amount/currency for Payment Intent) and your booking API (external_booking + internal_booking; add stripe_token after payment success).
521
526
  *
522
527
  * @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
523
- * @param {Object} [options] - { propertyId: string|number, propertyKey: string, clientBookingReference?: string }
524
- * @returns {{ stripe: { amount: number, currency: string }, external_booking: Object, internal_booking: Object }}
528
+ * @param {Object} [options] - { propertyId: string|number, propertyKey: string, clientBookingReference?: string, sandbox?: boolean }
529
+ * @returns {{ stripe: { amount: number, currency: string }, external_booking: Object, internal_booking: Object, sandbox?: boolean }}
525
530
  */
526
531
  function buildCheckoutPayload(state, options = {}) {
527
532
  const propertyId = options.propertyId != null ? String(options.propertyId) : '';
528
533
  const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
529
534
  const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
530
535
  const clientBookingReference = options.clientBookingReference || 'nuitee-booking-widget';
536
+ const sandbox = options.sandbox === true;
531
537
 
532
538
  const checkIn = state.checkIn;
533
539
  const checkOut = state.checkOut;
@@ -591,7 +597,7 @@ function buildCheckoutPayload(state, options = {}) {
591
597
  room_id: state.selectedRoom?.id ?? '',
592
598
  };
593
599
 
594
- return {
600
+ const out = {
595
601
  stripe: {
596
602
  amount: total,
597
603
  currency: currency.toLowerCase(),
@@ -599,6 +605,8 @@ function buildCheckoutPayload(state, options = {}) {
599
605
  external_booking,
600
606
  internal_booking,
601
607
  };
608
+ if (sandbox) out.sandbox = true;
609
+ return out;
602
610
  }
603
611
 
604
612
  /**
@@ -607,12 +615,23 @@ function buildCheckoutPayload(state, options = {}) {
607
615
  * Response: { clientSecret, confirmationToken }
608
616
  *
609
617
  * @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
610
- * @param {Object} [options] - { propertyKey: string }
611
- * @returns {{ rate_identifier: string, key: string, metadata: Object }}
618
+ * @param {Object} [options] - { propertyKey: string, sandbox?: boolean }
619
+ * @returns {{ rate_identifier: string, key: string, metadata: Object, sandbox?: boolean }}
612
620
  */
621
+ function generateUUID() {
622
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
623
+ return crypto.randomUUID();
624
+ }
625
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
626
+ const r = Math.random() * 16 | 0;
627
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
628
+ });
629
+ }
630
+
613
631
  function buildPaymentIntentPayload(state, options = {}) {
614
632
  const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
615
633
  const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
634
+ const sandbox = options.sandbox === true;
616
635
  const checkIn = state.checkIn;
617
636
  const checkOut = state.checkOut;
618
637
  const nights = checkIn && checkOut
@@ -632,7 +651,7 @@ function buildPaymentIntentPayload(state, options = {}) {
632
651
  ...(Array.isArray(o.childrenAges) && o.childrenAges.length ? { child_ages: o.childrenAges } : {}),
633
652
  }));
634
653
 
635
- return {
654
+ const out = {
636
655
  rate_identifier: rateIdentifier,
637
656
  key: propertyKey,
638
657
  metadata: {
@@ -648,8 +667,11 @@ function buildPaymentIntentPayload(state, options = {}) {
648
667
  email: g.email ?? '',
649
668
  occupancies: occupanciesForPayload,
650
669
  source_transaction: 'booking engine',
670
+ booking_code: generateUUID(),
651
671
  },
652
672
  };
673
+ if (sandbox) out.sandbox = true;
674
+ return out;
653
675
  }
654
676
 
655
677
  /**
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Color utilities for widget theming. Installers pass 4 base colors (background, text, primary, primaryText);
3
+ * we derive --card, --secondary, --muted, --border, --input-bg for a coherent theme.
4
+ */
5
+
6
+ function expandHex(hex) {
7
+ if (!hex || typeof hex !== 'string') return null;
8
+ const h = hex.replace(/^#/, '').trim();
9
+ if (h.length === 3) return '#' + h.split('').map((c) => c + c).join('');
10
+ return h.length === 6 ? '#' + h : null;
11
+ }
12
+
13
+ function hexToRgb(hex) {
14
+ const expanded = expandHex(hex);
15
+ if (!expanded) return null;
16
+ return [
17
+ parseInt(expanded.slice(1, 3), 16),
18
+ parseInt(expanded.slice(3, 5), 16),
19
+ parseInt(expanded.slice(5, 7), 16),
20
+ ];
21
+ }
22
+
23
+ function hexToHsl(hex) {
24
+ const expanded = expandHex(hex);
25
+ if (!expanded) return null;
26
+ let r = parseInt(expanded.slice(1, 3), 16) / 255;
27
+ let g = parseInt(expanded.slice(3, 5), 16) / 255;
28
+ let b = parseInt(expanded.slice(5, 7), 16) / 255;
29
+ const max = Math.max(r, g, b);
30
+ const min = Math.min(r, g, b);
31
+ let h, s;
32
+ const l = (max + min) / 2;
33
+ if (max === min) {
34
+ h = s = 0;
35
+ } else {
36
+ const d = max - min;
37
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
38
+ switch (max) {
39
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
40
+ case g: h = ((b - r) / d + 2) / 6; break;
41
+ case b: h = ((r - g) / d + 4) / 6; break;
42
+ default: h = 0;
43
+ }
44
+ }
45
+ return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
46
+ }
47
+
48
+ /**
49
+ * Derive full widget CSS variables from 4 base colors.
50
+ * @param {Object} c - { background, text, primary, primaryText, card? }
51
+ * @returns {Object} CSS variable names to values, e.g. { '--bg': '#1a1a1a', ... }
52
+ */
53
+ export function deriveWidgetStyles(c) {
54
+ if (!c || (!c.background && !c.text && !c.primary && !c.primaryText)) return {};
55
+ const bg = c.background || '#1a1a1a';
56
+ const fg = c.text || '#e0e0e0';
57
+ const primary = c.primary || '#3b82f6';
58
+ const primaryFg = c.primaryText || '#ffffff';
59
+ const cardExplicit = c.card;
60
+
61
+ const primaryRgb = hexToRgb(primary);
62
+ const bgHsl = hexToHsl(bg);
63
+ const fgHsl = hexToHsl(fg);
64
+
65
+ const styles = {
66
+ '--primary': primary,
67
+ '--primary-fg': primaryFg,
68
+ '--bg': bg,
69
+ '--fg': fg,
70
+ '--card-fg': fg,
71
+ };
72
+
73
+ if (primaryRgb) styles['--primary-rgb'] = `${primaryRgb[0]}, ${primaryRgb[1]}, ${primaryRgb[2]}`;
74
+
75
+ if (cardExplicit) {
76
+ styles['--card'] = cardExplicit + "40";
77
+ styles['--card-solid'] = cardExplicit;
78
+ } else if (bgHsl) {
79
+ const cardVal = `hsl(${bgHsl[0]}, ${bgHsl[1]}%, ${Math.min(95, bgHsl[2] + 2)}%)`;
80
+ styles['--card'] = cardVal;
81
+ styles['--card-solid'] = cardVal;
82
+ } else {
83
+ styles['--card'] = bg;
84
+ styles['--card-solid'] = bg;
85
+ }
86
+
87
+ if (bgHsl) {
88
+ styles['--secondary'] = `hsl(${bgHsl[0]}, ${bgHsl[1]}%, ${Math.min(95, bgHsl[2] + 10)}%)`;
89
+ styles['--border'] = `hsl(${bgHsl[0]}, ${bgHsl[1]}%, ${Math.min(95, bgHsl[2] + 14)}%)`;
90
+ styles['--input-bg'] = `hsl(${bgHsl[0]}, ${bgHsl[1]}%, ${Math.min(95, bgHsl[2] + 6)}%)`;
91
+ }
92
+
93
+ if (fgHsl) {
94
+ styles['--secondary-fg'] = `hsl(${fgHsl[0]}, ${fgHsl[1]}%, ${Math.max(20, fgHsl[2] - 10)}%)`;
95
+ styles['--muted'] = `hsl(${fgHsl[0]}, ${fgHsl[1]}%, ${Math.max(30, fgHsl[2] - 25)}%)`;
96
+ }
97
+
98
+ styles['--font-serif'] = "'Playfair Display', Georgia, serif";
99
+ styles['--font-sans'] = "'Inter', system-ui, sans-serif";
100
+ styles['--radius'] = '0.75rem';
101
+
102
+ return styles;
103
+ }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Config from CONFIG_URL (default https://ai.thehotelplanet.com/load-config). No .env used.
3
3
  * Property key is NOT from load-config; installers must set it (VITE_PROPERTY_KEY in app .env or propertyKey option).
4
+ * DEFAULT_COLORS from load-config CONFIG; installers can override via colors prop.
4
5
  */
5
6
  export const STRIPE_PUBLISHABLE_KEY = 'pk_test_51T0Se5EvQaQIixshRA8gbsI40NubMGwHlSWZmQM2LAx3jpVO0ThzVifqdTiMeGOyBfLNe8V9G8POGu8SjkvWOStM00H0i1U3uz';
6
7
  export const API_BASE_URL = 'https://ai.thehotelplanet.com';
7
8
  export const VITE_CONFIG_BASE_URL = 'https://ai.thehotelplanet.com/api';
8
9
  export const VITE_AWS_S3_PATH = 'https://nuitee-s3-temp.s3.us-west-1.amazonaws.com';
10
+ export const DEFAULT_COLORS = {"background":"#022c32","text":"#ffffff","primary":"#f59e0b","primaryText":"#ffffff","card":"#395b60"};
@@ -106,6 +106,145 @@
106
106
  font-weight: 500;
107
107
  }
108
108
 
109
+ /* ===== Config Error State ===== */
110
+ .booking-widget-config-error {
111
+ display: flex;
112
+ flex-direction: column;
113
+ align-items: center;
114
+ justify-content: center;
115
+ gap: 1.1em;
116
+ padding: 4em 2em;
117
+ text-align: center;
118
+ min-height: 300px;
119
+ }
120
+
121
+ .booking-widget-config-error__icon-wrap {
122
+ width: 5em;
123
+ height: 5em;
124
+ border-radius: 50%;
125
+ background: radial-gradient(circle, rgba(239,68,68,0.12) 0%, rgba(239,68,68,0.04) 100%);
126
+ border: 1.5px solid rgba(239, 68, 68, 0.22);
127
+ box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.06);
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ color: #f87171;
132
+ flex-shrink: 0;
133
+ margin-bottom: 0.25em;
134
+ }
135
+
136
+ .booking-widget-config-error__badge {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 0.4em;
140
+ font-size: 0.7em;
141
+ font-weight: 600;
142
+ letter-spacing: 0.12em;
143
+ text-transform: uppercase;
144
+ color: #f87171;
145
+ background: rgba(239, 68, 68, 0.1);
146
+ border: 1px solid rgba(239, 68, 68, 0.18);
147
+ border-radius: 99em;
148
+ padding: 0.3em 0.85em;
149
+ }
150
+
151
+ .booking-widget-config-error__title {
152
+ font-family: var(--font-serif, 'Playfair Display', Georgia, serif);
153
+ font-size: 1.35em;
154
+ font-weight: 600;
155
+ color: var(--fg, #e8e0d5);
156
+ margin: 0;
157
+ letter-spacing: -0.01em;
158
+ }
159
+
160
+ .booking-widget-config-error__desc {
161
+ font-size: 0.875em;
162
+ color: var(--secondary-fg, #a09080);
163
+ max-width: 25em;
164
+ line-height: 1.7;
165
+ margin: 0;
166
+ }
167
+
168
+ .booking-widget-config-error__desc code {
169
+ font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo, monospace;
170
+ font-size: 0.88em;
171
+ background: rgba(255, 255, 255, 0.06);
172
+ color: var(--primary, #f59e0b);
173
+ padding: 0.12em 0.45em;
174
+ border-radius: 0.3em;
175
+ border: 1px solid rgba(255, 255, 255, 0.09);
176
+ }
177
+
178
+ .booking-widget-config-error__divider {
179
+ width: 2.5em;
180
+ height: 1.5px;
181
+ background: var(--border, rgba(255,255,255,0.1));
182
+ border-radius: 1px;
183
+ }
184
+
185
+ .booking-widget-config-error__hint {
186
+ font-size: 0.78em;
187
+ color: var(--muted, #6b5f50);
188
+ max-width: 21em;
189
+ line-height: 1.6;
190
+ margin: 0;
191
+ }
192
+
193
+ .booking-widget-config-error__retry {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ gap: 0.45em;
197
+ padding: 0.55em 1.4em;
198
+ background: transparent;
199
+ color: var(--secondary-fg, #a09080);
200
+ border: 1.5px solid var(--border, rgba(255,255,255,0.13));
201
+ border-radius: 99em;
202
+ font-size: 0.8em;
203
+ font-family: var(--font-sans, system-ui, sans-serif);
204
+ font-weight: 500;
205
+ cursor: pointer;
206
+ letter-spacing: 0.02em;
207
+ transition: border-color 0.2s, color 0.2s, background 0.2s;
208
+ margin-top: 0.25em;
209
+ }
210
+
211
+ .booking-widget-config-error__retry:hover {
212
+ border-color: var(--primary, #f59e0b);
213
+ color: var(--primary, #f59e0b);
214
+ background: rgba(245, 158, 11, 0.06);
215
+ }
216
+
217
+ /* ===== Config Loading State ===== */
218
+ .booking-widget-config-loading {
219
+ display: flex;
220
+ flex-direction: column;
221
+ align-items: center;
222
+ justify-content: center;
223
+ gap: 1.25em;
224
+ padding: 4em 2em;
225
+ text-align: center;
226
+ min-height: 300px;
227
+ }
228
+
229
+ .booking-widget-config-loading__spinner {
230
+ width: 2.75em;
231
+ height: 2.75em;
232
+ border: 2px solid var(--border, rgba(255,255,255,0.1));
233
+ border-top-color: var(--primary, hsl(38,60%,55%));
234
+ border-radius: 50%;
235
+ animation: bw-spin 0.75s linear infinite;
236
+ }
237
+
238
+ @keyframes bw-spin {
239
+ to { transform: rotate(360deg); }
240
+ }
241
+
242
+ .booking-widget-config-loading__text {
243
+ font-size: 0.875em;
244
+ color: var(--muted, #888);
245
+ letter-spacing: 0.01em;
246
+ }
247
+
109
248
  /* ===== Step Indicator ===== */
110
249
  .booking-widget-step-indicator {
111
250
  display: flex;
@@ -448,7 +587,7 @@
448
587
  }
449
588
 
450
589
  .booking-widget-modal .date-trigger:hover {
451
- border-color: hsl(38, 60%, 55%, 0.5);
590
+ border-color: var(--primary);
452
591
  }
453
592
 
454
593
  .booking-widget-modal .date-trigger .placeholder {
@@ -472,7 +611,7 @@
472
611
  max-width: calc(100vw - 2em);
473
612
  box-sizing: border-box;
474
613
  z-index: 10;
475
- background: var(--card);
614
+ background: var(--card-solid, var(--card));
476
615
  border: 1px solid var(--border);
477
616
  border-radius: var(--radius);
478
617
  padding: 1em;
@@ -4,6 +4,7 @@ import { Calendar, Users, User, Check, MapPin, Phone, Square, CreditCard, Lock,
4
4
  import '../core/styles.css';
5
5
  import { createBookingApi, formatPrice, buildCheckoutPayload, buildPaymentIntentPayload } from '../core/booking-api.js';
6
6
  import { STRIPE_PUBLISHABLE_KEY, API_BASE_URL } from '../core/stripe-config.js';
7
+ import { fetchRuntimeConfig } from '../utils/config-service.js';
7
8
 
8
9
  const BASE_STEPS = [
9
10
  { key: 'dates', label: 'Dates & Guests', num: '01' },
@@ -31,6 +32,8 @@ const BookingWidget = ({
31
32
  apiSecret = '',
32
33
  propertyId = '',
33
34
  propertyKey = '',
35
+ /** 'sandbox' to pass sandbox=true on all proxy/API calls; omit or 'live' to not pass it. */
36
+ mode = '',
34
37
  availabilityBaseUrl = '',
35
38
  propertyBaseUrl = '',
36
39
  s3BaseUrl = '',
@@ -47,20 +50,23 @@ const BookingWidget = ({
47
50
  const env = typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {};
48
51
  const apiBase = (env.VITE_API_BASE_URL || API_BASE_URL || '').replace(/\/$/, '');
49
52
  const effectivePaymentIntentUrl = (apiBase ? apiBase + '/proxy/create-payment-intent' : '').trim();
53
+ const isSandbox = mode === 'sandbox';
54
+ const hasPropertyKey = !!(propertyKey && String(propertyKey).trim());
50
55
  const createPaymentIntent = useMemo(() => {
51
56
  if (typeof createPaymentIntentProp === 'function') return createPaymentIntentProp;
52
57
  if (!effectivePaymentIntentUrl) return null;
53
58
  return async (payload) => {
59
+ const url = effectivePaymentIntentUrl + (isSandbox ? '?sandbox=true' : '');
54
60
  const headers = { 'Content-Type': 'application/json' };
55
- if (effectivePaymentIntentUrl.includes('ngrok')) headers['ngrok-skip-browser-warning'] = 'true';
56
- const res = await fetch(effectivePaymentIntentUrl, { method: 'POST', headers, body: JSON.stringify(payload) });
61
+ if (url.includes('ngrok')) headers['ngrok-skip-browser-warning'] = 'true';
62
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
57
63
  if (!res.ok) throw new Error(await res.text());
58
64
  const data = await res.json();
59
65
  const clientSecret = data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret;
60
66
  const confirmationToken = data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token;
61
67
  return { clientSecret, confirmationToken };
62
68
  };
63
- }, [createPaymentIntentProp, effectivePaymentIntentUrl]);
69
+ }, [createPaymentIntentProp, effectivePaymentIntentUrl, isSandbox]);
64
70
  const onBookingComplete = useMemo(() => onBookingCompleteProp ?? null, [onBookingCompleteProp]);
65
71
  const hasStripe = Boolean(stripePublishableKey && typeof createPaymentIntent === 'function');
66
72
  const STEPS = useMemo(() => buildSteps(hasStripe), [hasStripe]);
@@ -93,6 +99,11 @@ const BookingWidget = ({
93
99
  const [checkoutShowPaymentForm, setCheckoutShowPaymentForm] = useState(false);
94
100
  const [isClosing, setIsClosing] = useState(false);
95
101
  const [isReadyForOpen, setIsReadyForOpen] = useState(false);
102
+ const [configLoading, setConfigLoading] = useState(false);
103
+ const [configLoaded, setConfigLoaded] = useState(false);
104
+ const [configError, setConfigError] = useState(null);
105
+ const [runtimeWidgetStyles, setRuntimeWidgetStyles] = useState({});
106
+ const [configRetryCount, setConfigRetryCount] = useState(0);
96
107
  const widgetRef = useRef(null);
97
108
  const stripeRef = useRef(null);
98
109
  const elementsRef = useRef(null);
@@ -116,11 +127,12 @@ const BookingWidget = ({
116
127
  s3BaseUrl: effectiveS3BaseUrl || undefined,
117
128
  propertyId: propertyId || undefined,
118
129
  propertyKey: propertyKey || undefined,
130
+ mode: mode === 'sandbox' ? 'sandbox' : undefined,
119
131
  headers: apiSecret ? { 'X-API-Key': apiSecret } : undefined,
120
132
  });
121
133
  }
122
134
  return null;
123
- }, [bookingApiProp, effectiveApiBaseUrl, effectiveAvailabilityBaseUrl, effectivePropertyBaseUrl, effectiveS3BaseUrl, apiSecret, propertyId, propertyKey]);
135
+ }, [bookingApiProp, effectiveApiBaseUrl, effectiveAvailabilityBaseUrl, effectivePropertyBaseUrl, effectiveS3BaseUrl, apiSecret, propertyId, propertyKey, mode]);
124
136
 
125
137
  useEffect(() => {
126
138
  if (isOpen && onOpen) onOpen();
@@ -145,7 +157,7 @@ const BookingWidget = ({
145
157
  setPaymentElementReady(false);
146
158
  return;
147
159
  }
148
- const paymentIntentPayload = buildPaymentIntentPayload(state, { propertyKey: propertyKey || undefined });
160
+ const paymentIntentPayload = buildPaymentIntentPayload(state, { propertyKey: propertyKey || undefined, sandbox: isSandbox });
149
161
  let mounted = true;
150
162
  setPaymentElementReady(false);
151
163
  setApiError(null);
@@ -182,7 +194,7 @@ const BookingWidget = ({
182
194
  }
183
195
  stripeRef.current = null;
184
196
  };
185
- }, [state.step, checkoutShowPaymentForm, stripePublishableKey, createPaymentIntent, onBookingComplete, state.checkIn, state.checkOut, state.rooms, state.occupancies, state.selectedRoom?.id, state.selectedRate?.id, state.guest, propertyId, propertyKey]);
197
+ }, [state.step, checkoutShowPaymentForm, stripePublishableKey, createPaymentIntent, onBookingComplete, state.checkIn, state.checkOut, state.rooms, state.occupancies, state.selectedRoom?.id, state.selectedRate?.id, state.guest, propertyId, propertyKey, isSandbox]);
186
198
 
187
199
  // After Stripe confirms payment, poll confirmation endpoint every 2s until status === 'confirmed'
188
200
  useEffect(() => {
@@ -192,7 +204,7 @@ const BookingWidget = ({
192
204
  const pollOnce = async () => {
193
205
  if (cancelled) return;
194
206
  try {
195
- const url = `${effectiveConfirmationBaseUrl}/proxy/confirmation/${encodeURIComponent(String(confirmationToken).trim())}`;
207
+ const url = `${effectiveConfirmationBaseUrl}/proxy/confirmation/${encodeURIComponent(String(confirmationToken).trim())}${isSandbox ? '?sandbox=true' : ''}`;
196
208
  const res = await fetch(url, { method: 'POST' });
197
209
  if (!res.ok) throw new Error(await res.text());
198
210
  const data = await res.json();
@@ -212,28 +224,45 @@ const BookingWidget = ({
212
224
  cancelled = true;
213
225
  if (timer) clearTimeout(timer);
214
226
  };
215
- }, [state.step, confirmationToken, effectiveConfirmationBaseUrl]);
227
+ }, [state.step, confirmationToken, effectiveConfirmationBaseUrl, isSandbox]);
216
228
 
217
- // Apply custom colors
229
+ // Fetch runtime config (colors) from /load-config on mount and when propertyKey/colors change.
230
+ // The config service caches API results so re-renders triggered by other prop changes are cheap.
218
231
  useEffect(() => {
219
- if (widgetRef.current && colors) {
220
- const style = widgetRef.current.style;
221
- if (colors.background) {
222
- style.setProperty('--bg', colors.background);
223
- style.setProperty('--card', colors.background);
224
- }
225
- if (colors.text) {
226
- style.setProperty('--fg', colors.text);
227
- style.setProperty('--card-fg', colors.text);
228
- }
229
- if (colors.primary) {
230
- style.setProperty('--primary', colors.primary);
231
- }
232
- if (colors.primaryText) {
233
- style.setProperty('--primary-fg', colors.primaryText);
234
- }
232
+ if (!propertyKey || !String(propertyKey).trim()) {
233
+ setConfigError('propertyKey is required to initialize the booking widget.');
234
+ setConfigLoading(false);
235
+ setConfigLoaded(false);
236
+ setRuntimeWidgetStyles({});
237
+ return;
235
238
  }
236
- }, [colors, isOpen]);
239
+ let cancelled = false;
240
+ setConfigLoading(true);
241
+ setConfigError(null);
242
+ setConfigLoaded(false);
243
+ fetchRuntimeConfig(propertyKey, colors)
244
+ .then(({ widgetStyles }) => {
245
+ if (cancelled) return;
246
+ setRuntimeWidgetStyles(widgetStyles);
247
+ setConfigLoaded(true);
248
+ setConfigLoading(false);
249
+ })
250
+ .catch((err) => {
251
+ if (cancelled) return;
252
+ setConfigError(err?.message || 'Failed to load widget configuration.');
253
+ setConfigLoading(false);
254
+ });
255
+ return () => { cancelled = true; };
256
+ }, [propertyKey, colors, configRetryCount]);
257
+
258
+ // Apply resolved CSS custom properties to the widget element.
259
+ useEffect(() => {
260
+ if (!widgetRef.current) return;
261
+ const style = widgetRef.current.style;
262
+ Object.entries(runtimeWidgetStyles).forEach(([key, val]) => {
263
+ if (val != null && val !== '') style.setProperty(key, val);
264
+ });
265
+ }, [runtimeWidgetStyles, isOpen]);
237
266
 
238
267
  const getNights = () => {
239
268
  if (!state.checkIn || !state.checkOut) return 0;
@@ -272,6 +301,8 @@ const BookingWidget = ({
272
301
  const stepIndex = (key) => STEPS.findIndex(s => s.key === key);
273
302
 
274
303
  const goToStep = (step) => {
304
+ // Block room/rate navigation until runtime config (colors) has been loaded.
305
+ if ((step === 'rooms' || step === 'rates') && !configLoaded) return;
275
306
  setApiError(null);
276
307
  if (step !== 'summary' && step !== 'payment') setCheckoutShowPaymentForm(false);
277
308
  if (step === 'payment') setCheckoutShowPaymentForm(true);
@@ -431,7 +462,7 @@ const BookingWidget = ({
431
462
  const confirmReservation = () => {
432
463
  const canSubmit = state.guest.firstName && state.guest.lastName && state.guest.email;
433
464
  if (!canSubmit) return;
434
- const payload = buildCheckoutPayload(state, { propertyId: propertyId || undefined, propertyKey: propertyKey || undefined });
465
+ const payload = buildCheckoutPayload(state, { propertyId: propertyId || undefined, propertyKey: propertyKey || undefined, sandbox: isSandbox });
435
466
  // Summary step with Stripe: go to payment step (payment form loads there)
436
467
  if (state.step === 'summary' && stripePublishableKey && typeof createPaymentIntent === 'function') {
437
468
  setApiError(null);
@@ -1175,15 +1206,59 @@ const BookingWidget = ({
1175
1206
  onTransitionEnd={handleTransitionEnd}
1176
1207
  >
1177
1208
  <button className="booking-widget-close" onClick={requestClose}><X size={24} /></button>
1178
- {renderStepIndicator()}
1179
- <div className="booking-widget-step-content">
1180
- {state.step === 'dates' && renderDatesStep()}
1181
- {state.step === 'rooms' && renderRoomsStep()}
1182
- {state.step === 'rates' && renderRatesStep()}
1183
- {state.step === 'summary' && renderSummaryStep()}
1184
- {state.step === 'payment' && renderPaymentStep()}
1185
- {state.step === 'confirmation' && renderConfirmationStep()}
1186
- </div>
1209
+ {configLoaded && hasPropertyKey && renderStepIndicator()}
1210
+ {(!propertyKey || (typeof propertyKey === 'string' && !propertyKey.trim())) ? (
1211
+ <div className="booking-widget-config-error" role="alert">
1212
+ <div className="booking-widget-config-error__icon-wrap">
1213
+ <Lock size={22} strokeWidth={1.5} />
1214
+ </div>
1215
+ <span className="booking-widget-config-error__badge">
1216
+ <Lock size={10} strokeWidth={2} /> Missing Configuration
1217
+ </span>
1218
+ <h3 className="booking-widget-config-error__title">Widget Not Configured</h3>
1219
+ <p className="booking-widget-config-error__desc">
1220
+ A <code>propertyKey</code> prop is required to initialize this booking widget. Please provide it to load rooms and availability.
1221
+ </p>
1222
+ <div className="booking-widget-config-error__divider" />
1223
+ <p className="booking-widget-config-error__hint">
1224
+ Contact the site administrator to configure this widget.
1225
+ </p>
1226
+ </div>
1227
+ ) : configError ? (
1228
+ <div className="booking-widget-config-error" role="alert">
1229
+ <div className="booking-widget-config-error__icon-wrap">
1230
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
1231
+ </div>
1232
+ <span className="booking-widget-config-error__badge">Configuration Error</span>
1233
+ <h3 className="booking-widget-config-error__title">Could Not Load Config</h3>
1234
+ <p className="booking-widget-config-error__desc">{configError}</p>
1235
+ <div className="booking-widget-config-error__divider" />
1236
+ <p className="booking-widget-config-error__hint">
1237
+ Please try again or contact support.
1238
+ </p>
1239
+ <button
1240
+ className="booking-widget-config-error__retry"
1241
+ onClick={() => setConfigRetryCount(c => c + 1)}
1242
+ >
1243
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1244
+ Try Again
1245
+ </button>
1246
+ </div>
1247
+ ) : configLoading ? (
1248
+ <div className="booking-widget-config-loading">
1249
+ <div className="booking-widget-config-loading__spinner" />
1250
+ <span className="booking-widget-config-loading__text">Loading configuration…</span>
1251
+ </div>
1252
+ ) : (
1253
+ <div className="booking-widget-step-content">
1254
+ {state.step === 'dates' && renderDatesStep()}
1255
+ {state.step === 'rooms' && renderRoomsStep()}
1256
+ {state.step === 'rates' && renderRatesStep()}
1257
+ {state.step === 'summary' && renderSummaryStep()}
1258
+ {state.step === 'payment' && renderPaymentStep()}
1259
+ {state.step === 'confirmation' && renderConfirmationStep()}
1260
+ </div>
1261
+ )}
1187
1262
  </div>
1188
1263
  </>
1189
1264
  );