@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.
@@ -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;
@@ -1,4 +1,4 @@
1
- (function(){'use strict';window.__BOOKING_WIDGET_API_BASE_URL__='https://ai.thehotelplanet.com';window.__BOOKING_WIDGET_STRIPE_KEY__='pk_test_51T0Se5EvQaQIixshRA8gbsI40NubMGwHlSWZmQM2LAx3jpVO0ThzVifqdTiMeGOyBfLNe8V9G8POGu8SjkvWOStM00H0i1U3uz';})();
1
+ (function(){'use strict';window.__BOOKING_WIDGET_API_BASE_URL__='https://ai.thehotelplanet.com';window.__BOOKING_WIDGET_STRIPE_KEY__='pk_test_51T0Se5EvQaQIixshRA8gbsI40NubMGwHlSWZmQM2LAx3jpVO0ThzVifqdTiMeGOyBfLNe8V9G8POGu8SjkvWOStM00H0i1U3uz';window.__BOOKING_WIDGET_DEFAULT_COLORS__={"background":"#022c32","text":"#ffffff","primary":"#f59e0b","primaryText":"#ffffff","card":"#395b60"};})();
2
2
  // ===== Icons (Lucide/shadcn) =====
3
3
  const icons = {
4
4
  calendar: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>`,
@@ -51,6 +51,54 @@ function escapeHTML(value) {
51
51
  .replace(/'/g, '&#39;');
52
52
  }
53
53
 
54
+ function deriveWidgetStyles(c) {
55
+ if (!c || (!c.background && !c.text && !c.primary && !c.primaryText)) return {};
56
+ var expandHex = function (hex) {
57
+ if (!hex || typeof hex !== 'string') return null;
58
+ var h = hex.replace(/^#/, '').trim();
59
+ if (h.length === 3) return '#' + h.split('').map(function (x) { return x + x; }).join('');
60
+ return h.length === 6 ? '#' + h : null;
61
+ };
62
+ var hexToRgb = function (hex) {
63
+ var x = expandHex(hex);
64
+ if (!x) return null;
65
+ return [parseInt(x.slice(1, 3), 16), parseInt(x.slice(3, 5), 16), parseInt(x.slice(5, 7), 16)];
66
+ };
67
+ var hexToHsl = function (hex) {
68
+ var x = expandHex(hex);
69
+ if (!x) return null;
70
+ var r = parseInt(x.slice(1, 3), 16) / 255, g = parseInt(x.slice(3, 5), 16) / 255, b = parseInt(x.slice(5, 7), 16) / 255;
71
+ var max = Math.max(r, g, b), min = Math.min(r, g, b), h, s, l = (max + min) / 2;
72
+ if (max === min) h = s = 0;
73
+ else {
74
+ var d = max - min;
75
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
76
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
77
+ else if (max === g) h = ((b - r) / d + 2) / 6;
78
+ else h = ((r - g) / d + 4) / 6;
79
+ }
80
+ return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
81
+ };
82
+ var bg = c.background || '#1a1a1a', fg = c.text || '#e0e0e0', primary = c.primary || '#3b82f6', primaryFg = c.primaryText || '#ffffff';
83
+ var primaryRgb = hexToRgb(primary), bgHsl = hexToHsl(bg), fgHsl = hexToHsl(fg);
84
+ var styles = { '--primary': primary, '--primary-fg': primaryFg, '--bg': bg, '--fg': fg, '--card-fg': fg };
85
+ if (primaryRgb) styles['--primary-rgb'] = primaryRgb[0] + ', ' + primaryRgb[1] + ', ' + primaryRgb[2];
86
+ styles['--card'] = c.card || (bgHsl ? 'hsl(' + bgHsl[0] + ', ' + bgHsl[1] + '%, ' + Math.min(95, bgHsl[2] + 2) + '%)' : bg);
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
+ if (fgHsl) {
93
+ styles['--secondary-fg'] = 'hsl(' + fgHsl[0] + ', ' + fgHsl[1] + '%, ' + Math.max(20, fgHsl[2] - 10) + '%)';
94
+ styles['--muted'] = 'hsl(' + fgHsl[0] + ', ' + fgHsl[1] + '%, ' + Math.max(30, fgHsl[2] - 25) + '%)';
95
+ }
96
+ styles['--font-serif'] = "'Playfair Display', Georgia, serif";
97
+ styles['--font-sans'] = "'Inter', system-ui, sans-serif";
98
+ styles['--radius'] = '0.75rem';
99
+ return styles;
100
+ }
101
+
54
102
  function getDefaultRooms() {
55
103
  return [];
56
104
  }
@@ -59,14 +107,19 @@ function getDefaultRates() {
59
107
  return [];
60
108
  }
61
109
 
110
+ // In-memory cache shared across all widget instances (keyed by propertyKey).
111
+ var __bwConfigCache = {};
112
+
62
113
  // ===== Core Widget Class =====
63
114
  class BookingWidget {
64
115
  constructor(options = {}) {
65
116
  const apiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
66
117
  const builtInStripeKey = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_STRIPE_KEY__) ? window.__BOOKING_WIDGET_STRIPE_KEY__ : '';
118
+ const builtInMode = options.mode;
67
119
  const builtInCreatePaymentIntent = (apiBase && typeof window !== 'undefined')
68
120
  ? function (payload) {
69
- return fetch(apiBase.replace(/\/$/, '') + '/proxy/create-payment-intent', {
121
+ const url = apiBase.replace(/\/$/, '') + '/proxy/create-payment-intent' + (builtInMode === 'sandbox' ? '?sandbox=true' : '');
122
+ return fetch(url, {
70
123
  method: 'POST',
71
124
  headers: { 'Content-Type': 'application/json' },
72
125
  body: JSON.stringify(payload),
@@ -99,19 +152,27 @@ class BookingWidget {
99
152
  apiSecret: options.apiSecret || null,
100
153
  propertyId: options.propertyId != null && options.propertyId !== '' ? options.propertyId : null,
101
154
  propertyKey: options.propertyKey || null,
155
+ /** 'sandbox' to pass sandbox=true on all proxy/API calls; omit or 'live' to not pass it. */
156
+ mode: options.mode || null,
102
157
  availabilityBaseUrl: options.availabilityBaseUrl || null,
103
158
  propertyBaseUrl: options.propertyBaseUrl || null,
104
159
  s3BaseUrl: options.s3BaseUrl || null,
105
160
  /** Base URL for confirmation status polling (same as API_BASE_URL). Default https://ai.thehotelplanet.com */
106
161
  confirmationBaseUrl: options.confirmationBaseUrl || 'https://ai.thehotelplanet.com',
107
- // Color customization options
108
- colors: {
109
- background: options.colors?.background || null,
110
- text: options.colors?.text || null,
111
- primary: options.colors?.primary || null,
112
- primaryText: options.colors?.primaryText || null,
113
- ...options.colors
114
- },
162
+ // Color customization: CONFIG defaults from load-config, installer colors override
163
+ colors: (function () {
164
+ const dc = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_DEFAULT_COLORS__) ? window.__BOOKING_WIDGET_DEFAULT_COLORS__ : {};
165
+ const inst = options.colors && typeof options.colors === 'object' ? options.colors : {};
166
+ return {
167
+ background: inst.background ?? dc.background ?? null,
168
+ text: inst.text ?? dc.text ?? null,
169
+ primary: inst.primary ?? dc.primary ?? null,
170
+ primaryText: inst.primaryText ?? dc.primaryText ?? null,
171
+ // When card omitted, use installer's background for consistency
172
+ card: inst.card ?? inst.background ?? dc.card ?? dc.background ?? null,
173
+ ...inst
174
+ };
175
+ })(),
115
176
  ...options
116
177
  };
117
178
 
@@ -125,6 +186,7 @@ class BookingWidget {
125
186
  s3BaseUrl: opts.s3BaseUrl || undefined,
126
187
  propertyId: opts.propertyId != null && opts.propertyId !== '' ? String(opts.propertyId) : undefined,
127
188
  propertyKey: opts.propertyKey || undefined,
189
+ mode: opts.mode === 'sandbox' ? 'sandbox' : undefined,
128
190
  headers: opts.apiSecret ? { 'X-API-Key': opts.apiSecret } : undefined,
129
191
  })
130
192
  : null);
@@ -161,6 +223,16 @@ class BookingWidget {
161
223
  this.stripeInstance = null;
162
224
  this.elementsInstance = null;
163
225
 
226
+ // Store raw installer colors so they can be re-merged with API-fetched colors.
227
+ this._rawInstallerColors = options.colors && typeof options.colors === 'object'
228
+ ? Object.assign({}, options.colors)
229
+ : {};
230
+
231
+ // Config fetch state: 'idle' | 'loading' | 'loaded' | 'error'
232
+ this._configState = 'idle';
233
+ this._configError = null;
234
+ this._configPromise = null;
235
+
164
236
  this.calendarMonth = null;
165
237
  this.calendarYear = null;
166
238
  this.pickState = 0;
@@ -224,8 +296,13 @@ class BookingWidget {
224
296
  // ===== Widget Open/Close =====
225
297
  open() {
226
298
  if (!this.container) this.init();
299
+ if (!this.overlay || !this.widget) return; // container element not found
227
300
  this.overlay.classList.add('active');
228
301
  this.widget.classList.add('active');
302
+ // Only fetch config when propertyKey is present; missing-key is rendered inside the modal.
303
+ if (this.options.propertyKey && String(this.options.propertyKey).trim() && this._configState === 'idle') {
304
+ this._fetchRuntimeConfig();
305
+ }
229
306
  this.render();
230
307
  if (this.options.onOpen) this.options.onOpen();
231
308
  }
@@ -261,6 +338,15 @@ class BookingWidget {
261
338
  }
262
339
 
263
340
  goToStep(step) {
341
+ // Block room/rate navigation until runtime config (colors + styles) has loaded.
342
+ if ((step === 'rooms' || step === 'rates') && this._configState !== 'loaded') {
343
+ if (this._configState === 'loading' && this._configPromise) {
344
+ this._configPromise.then(() => {
345
+ if (this._configState === 'loaded') this.goToStep(step);
346
+ });
347
+ }
348
+ return;
349
+ }
264
350
  const doRender = () => {
265
351
  if (step !== 'summary' && step !== 'payment') {
266
352
  this.checkoutShowPaymentForm = false;
@@ -321,14 +407,14 @@ class BookingWidget {
321
407
  }
322
408
 
323
409
  this.container = container;
324
-
325
- // Create overlay
410
+
411
+ // Always create the overlay and modal so the error is shown inside the dialog,
412
+ // consistent with the React and Vue behaviour.
326
413
  this.overlay = document.createElement('div');
327
414
  this.overlay.className = 'booking-widget-overlay';
328
415
  this.overlay.addEventListener('click', () => this.close());
329
416
  container.appendChild(this.overlay);
330
417
 
331
- // Create widget modal
332
418
  this.widget = document.createElement('div');
333
419
  this.widget.className = 'booking-widget-modal';
334
420
  this.widget.innerHTML = `
@@ -343,35 +429,82 @@ class BookingWidget {
343
429
  });
344
430
  container.appendChild(this.widget);
345
431
 
346
- // Apply custom colors
347
432
  this.applyColors();
348
-
349
- // Inject CSS if not already present
350
433
  this.injectCSS();
351
434
  }
352
435
 
353
436
  applyColors() {
354
437
  if (!this.widget) return;
355
-
356
438
  const colors = this.options.colors;
357
439
  if (!colors) return;
358
-
359
- const style = this.widget.style;
360
-
361
- if (colors.background) {
362
- style.setProperty('--bg', colors.background);
363
- style.setProperty('--card', colors.background);
364
- }
365
- if (colors.text) {
366
- style.setProperty('--fg', colors.text);
367
- style.setProperty('--card-fg', colors.text);
368
- }
369
- if (colors.primary) {
370
- style.setProperty('--primary', colors.primary);
371
- }
372
- if (colors.primaryText) {
373
- style.setProperty('--primary-fg', colors.primaryText);
440
+ const styles = deriveWidgetStyles(colors);
441
+ const el = this.widget.style;
442
+ for (var k in styles) if (styles[k] != null && styles[k] !== '') el.setProperty(k, styles[k]);
443
+ }
444
+
445
+ /**
446
+ * Merge API-fetched colors with installer-provided raw colors and update
447
+ * this.options.colors so applyColors() uses the runtime values.
448
+ */
449
+ _applyApiColors(apiColors) {
450
+ const inst = this._rawInstallerColors || {};
451
+ this.options.colors = {
452
+ background: inst.background != null ? inst.background : (apiColors.background || null),
453
+ text: inst.text != null ? inst.text : (apiColors.text || null),
454
+ primary: inst.primary != null ? inst.primary : (apiColors.primary || null),
455
+ primaryText: inst.primaryText != null ? inst.primaryText : (apiColors.primaryText || null),
456
+ card: inst.card != null ? inst.card : (inst.background != null ? inst.background : (apiColors.card || null)),
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Fetch runtime styling config from /load-config.
462
+ * Caches result in __bwConfigCache (module-level, shared across instances).
463
+ * On success: merges colors, applies them, and re-renders.
464
+ * On error: sets _configState to 'error' and re-renders to show the error banner.
465
+ */
466
+ _fetchRuntimeConfig() {
467
+ const self = this;
468
+ const propertyKey = this.options.propertyKey;
469
+ const key = String(propertyKey).trim();
470
+
471
+ if (__bwConfigCache[key]) {
472
+ this._applyApiColors(__bwConfigCache[key]);
473
+ this._configState = 'loaded';
474
+ this.applyColors();
475
+ return Promise.resolve(__bwConfigCache[key]);
374
476
  }
477
+
478
+ this._configState = 'loading';
479
+ this.render();
480
+
481
+ const url = 'https://ai.thehotelplanet.com/load-config?apikey=' + encodeURIComponent(key) + '&mode=sandbox';
482
+ this._configPromise = fetch(url)
483
+ .then(function (res) {
484
+ if (!res.ok) throw new Error('Failed to load widget configuration (HTTP ' + res.status + ').');
485
+ return res.json();
486
+ })
487
+ .then(function (data) {
488
+ const apiColors = {};
489
+ if (data.widgetBackground) apiColors.background = data.widgetBackground;
490
+ if (data.widgetTextColor) apiColors.text = data.widgetTextColor;
491
+ if (data.primaryColor) apiColors.primary = data.primaryColor;
492
+ if (data.buttonTextColor) apiColors.primaryText = data.buttonTextColor;
493
+ if (data.widgetCardColor) apiColors.card = data.widgetCardColor;
494
+ __bwConfigCache[key] = apiColors;
495
+ self._applyApiColors(apiColors);
496
+ self._configState = 'loaded';
497
+ self.applyColors();
498
+ self.render();
499
+ return apiColors;
500
+ })
501
+ .catch(function (err) {
502
+ self._configError = err.message || 'Failed to load widget configuration.';
503
+ self._configState = 'error';
504
+ self.render();
505
+ });
506
+
507
+ return this._configPromise;
375
508
  }
376
509
 
377
510
  injectCSS() {
@@ -392,6 +525,7 @@ class BookingWidget {
392
525
  window.__bookingWidgetRendering = true;
393
526
  try {
394
527
  this.renderStepIndicator();
528
+ this.renderPropertyKeyMessage();
395
529
  this.renderStepContent();
396
530
  } finally {
397
531
  window.__bookingWidgetRendering = false;
@@ -402,7 +536,9 @@ class BookingWidget {
402
536
  renderStepIndicator() {
403
537
  const el = this.widget.querySelector('.booking-widget-step-indicator');
404
538
  if (!el) return;
405
- if (this.state.step === 'confirmation') {
539
+ const key = this.options.propertyKey;
540
+ const hasPropertyKey = key != null && key !== '' && (typeof key !== 'string' || key.trim() !== '');
541
+ if (!hasPropertyKey || this._configState !== 'loaded' || this.state.step === 'confirmation') {
406
542
  el.innerHTML = '';
407
543
  return;
408
544
  }
@@ -422,9 +558,79 @@ class BookingWidget {
422
558
  el.innerHTML = html;
423
559
  }
424
560
 
561
+ renderPropertyKeyMessage() {
562
+ const el = this.widget.querySelector('.booking-widget-property-key-message');
563
+ if (!el) return;
564
+ // Config errors are now rendered inside renderStepContent via the full error card.
565
+ el.style.display = 'none';
566
+ }
567
+
568
+ _retryConfigFetch() {
569
+ this._configState = 'idle';
570
+ this._configError = null;
571
+ this._configPromise = null;
572
+ this._fetchRuntimeConfig();
573
+ }
574
+
575
+ _configErrorHTML(type, title, body, hint) {
576
+ const lockIcon = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`;
577
+ const warnIcon = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="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"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`;
578
+ const retryIcon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><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"></path></svg>`;
579
+ const icon = type === 'missing' ? lockIcon : warnIcon;
580
+ const badgeLabel = type === 'missing'
581
+ ? `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg> Missing Configuration`
582
+ : 'Configuration Error';
583
+ const retryBtn = type === 'fetch'
584
+ ? `<button class="booking-widget-config-error__retry" onclick="window.bookingWidgetInstance && window.bookingWidgetInstance._retryConfigFetch()">${retryIcon} Try Again</button>`
585
+ : '';
586
+ return `<div class="booking-widget-config-error" role="alert">
587
+ <div class="booking-widget-config-error__icon-wrap">${icon}</div>
588
+ <span class="booking-widget-config-error__badge">${badgeLabel}</span>
589
+ <h3 class="booking-widget-config-error__title">${escapeHTML(title)}</h3>
590
+ <p class="booking-widget-config-error__desc">${body}</p>
591
+ <div class="booking-widget-config-error__divider"></div>
592
+ <p class="booking-widget-config-error__hint">${escapeHTML(hint)}</p>
593
+ ${retryBtn}
594
+ </div>`;
595
+ }
596
+
425
597
  // ===== Step Renderers =====
426
598
  renderStepContent() {
427
599
  const el = this.widget.querySelector('.booking-widget-step-content');
600
+ if (!el) return;
601
+
602
+ // Missing propertyKey — show error card inside the modal, same as React/Vue.
603
+ const key = this.options.propertyKey;
604
+ if (!key || !String(key).trim()) {
605
+ el.innerHTML = this._configErrorHTML(
606
+ 'missing',
607
+ 'Widget Not Configured',
608
+ 'A <code>propertyKey</code> option is required to initialize this booking widget. Please provide it to load rooms and availability.',
609
+ 'Contact the site administrator to configure this widget.'
610
+ );
611
+ return;
612
+ }
613
+
614
+ if (this._configState === 'loading') {
615
+ el.innerHTML = `<div class="booking-widget-config-loading">
616
+ <div class="booking-widget-config-loading__spinner"></div>
617
+ <span class="booking-widget-config-loading__text">Loading configuration\u2026</span>
618
+ </div>`;
619
+ return;
620
+ }
621
+ if (this._configState === 'error') {
622
+ el.innerHTML = this._configErrorHTML(
623
+ 'fetch',
624
+ 'Could Not Load Config',
625
+ escapeHTML(this._configError || 'An unexpected error occurred.'),
626
+ 'Please try again or contact support.'
627
+ );
628
+ return;
629
+ }
630
+ if (this._configState !== 'loaded') {
631
+ el.innerHTML = '';
632
+ return;
633
+ }
428
634
  switch (this.state.step) {
429
635
  case 'dates':
430
636
  el.innerHTML = this.renderDatesStep();
@@ -949,9 +1155,10 @@ class BookingWidget {
949
1155
  if (this.state.step !== 'payment' || !this.checkoutShowPaymentForm || !this.options.stripePublishableKey || typeof this.options.createPaymentIntent !== 'function') return;
950
1156
  const buildPaymentIntentPayload = typeof window !== 'undefined' && window.buildPaymentIntentPayload;
951
1157
  const buildCheckoutPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
1158
+ const sandbox = this.options.mode === 'sandbox';
952
1159
  const paymentIntentPayload = buildPaymentIntentPayload
953
- ? buildPaymentIntentPayload(this.state, { propertyKey: this.options.propertyKey ?? undefined })
954
- : (buildCheckoutPayload ? buildCheckoutPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null);
1160
+ ? buildPaymentIntentPayload(this.state, { propertyKey: this.options.propertyKey ?? undefined, sandbox })
1161
+ : (buildCheckoutPayload ? buildCheckoutPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined, sandbox }) : null);
955
1162
  if (!paymentIntentPayload) return;
956
1163
  const self = this;
957
1164
  this.paymentElementReady = false;
@@ -1030,7 +1237,8 @@ class BookingWidget {
1030
1237
  const canSubmit = this.state.guest.firstName && this.state.guest.lastName && this.state.guest.email;
1031
1238
  if (!canSubmit) return;
1032
1239
  const buildPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
1033
- const payload = buildPayload ? buildPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null;
1240
+ const sandbox = this.options.mode === 'sandbox';
1241
+ const payload = buildPayload ? buildPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined, sandbox }) : null;
1034
1242
  const hasStripe = this.options.stripePublishableKey && typeof this.options.createPaymentIntent === 'function';
1035
1243
 
1036
1244
  // Summary step with Stripe: go to payment step (form loads there)
@@ -1177,7 +1385,7 @@ class BookingWidget {
1177
1385
  const token = String(confirmationToken || '').trim();
1178
1386
  if (!token) throw new Error('Missing confirmation token');
1179
1387
  const base = String(this.options.confirmationBaseUrl || 'https://ai.thehotelplanet.com').replace(/\/$/, '');
1180
- const url = `${base}/proxy/confirmation/${encodeURIComponent(token)}`;
1388
+ const url = `${base}/proxy/confirmation/${encodeURIComponent(token)}${this.options.mode === 'sandbox' ? '?sandbox=true' : ''}`;
1181
1389
  const res = await fetch(url, { method: 'POST' });
1182
1390
  if (!res.ok) throw new Error(await res.text());
1183
1391
  return await res.json();