@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.
package/USAGE.md CHANGED
@@ -254,7 +254,7 @@ Only the following props/options are for you to set. API URLs, Stripe, and booki
254
254
  | `onComplete` | function | Called when the booking is completed, with booking data. |
255
255
  | `onOpen` | function | Optional. Called when the widget opens. |
256
256
  | `propertyKey` | string | Property key for real rooms/rates/booking; omit for demo data. |
257
- | `colors` | object | Optional. `{ background, text, primary, primaryText }`. |
257
+ | `colors` | object | Optional. Pass 4 colors: `{ background, text, primary, primaryText }`. Card, muted, border, etc. are derived. Add `card` to override. |
258
258
 
259
259
  ### Vanilla constructor options
260
260
 
@@ -266,7 +266,7 @@ Only the following props/options are for you to set. API URLs, Stripe, and booki
266
266
  | `onClose` | function | Called when the user closes the widget. |
267
267
  | `onComplete` | function | Called when the booking is completed. |
268
268
  | `onOpen` | function | Optional. Called when the widget opens. |
269
- | `colors` | object | Optional. `{ background, text, primary, primaryText }`. |
269
+ | `colors` | object | Optional. Pass 4 colors: `{ background, text, primary, primaryText }`. Card, muted, border, etc. are derived. Add `card` to override. |
270
270
 
271
271
  ### Vanilla methods
272
272
 
@@ -286,4 +286,4 @@ Only the following props/options are for you to set. API URLs, Stripe, and booki
286
286
  | Use in vanilla (no build) | Load the standalone script and CSS, `new BookingWidget({ containerId, propertyKey, ... })`, then `init()` and `open()`. |
287
287
  | Use in vanilla (with build) | Import `@nuitee/booking-widget` and `@nuitee/booking-widget/css`, pass `propertyKey` and callbacks, then `open()`. |
288
288
  | Real rooms/rates/booking | Pass `propertyKey`. The widget uses its own runtime config for API and payments. |
289
- | Custom colors | Pass `colors: { background, text, primary, primaryText }`. |
289
+ | Custom colors | Pass `colors: { background, text, primary, primaryText }`. Card, muted, border, input-bg derive from these. |
@@ -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
  /**
3
3
  * Shared Booking API layer for the @nuitee/booking-widget.
4
4
  * Used by Vanilla JS, Vue, and React variants so integration lives in one place.
@@ -198,6 +198,7 @@ function deriveRatePolicyDetail(avRate) {
198
198
  * @param {string} [config.s3BaseUrl] - Base URL for room images (e.g. from VITE_AWS_S3_PATH).
199
199
  * @param {string|number} [config.propertyId] - Property ID (legacy). Prefer propertyKey for proxy endpoints.
200
200
  * @param {string} [config.propertyKey] - Property key for proxy endpoints (e.g. VITE_PROPERTY_KEY). Used in /proxy/availability and /proxy/ari-properties?key=.
201
+ * @param {string} [config.mode] - 'sandbox' to pass sandbox=true on all proxy/API calls; omit or 'live' to not pass it.
201
202
  * @param {string} [config.currency] - Currency code (e.g. 'MAD'). Default 'MAD'.
202
203
  * @param {Object} [config.headers] - Optional. Static headers for each request (e.g. { 'X-API-Key': '...' }).
203
204
  * @param {function(): Object} [config.getHeaders] - Optional. Return headers for each request (merged after headers). Use for dynamic headers.
@@ -216,6 +217,7 @@ function createBookingApi(config = {}) {
216
217
  if (rawKey && !propertyKey && typeof console !== 'undefined' && console.warn) {
217
218
  console.warn('[booking-api] Property key must start with "book_engine_". Ignoring invalid value. Set VITE_PROPERTY_KEY in your .env (see .env.example).');
218
219
  }
220
+ const sandbox = config.mode === 'sandbox';
219
221
  const currency = config.currency || 'MAD';
220
222
  const staticHeaders = config.headers || {};
221
223
  const getHeaders = config.getHeaders || (() => ({}));
@@ -280,8 +282,11 @@ function createBookingApi(config = {}) {
280
282
  // Fetch get_properties first to get currency_code (e.g. 'EUR') and room details
281
283
  let propertyRooms = {};
282
284
  let propertyCurrency = currency;
285
+ const propQuery = propertyKey
286
+ ? `key=${encodeURIComponent(propertyKey)}${sandbox ? '&sandbox=true' : ''}`
287
+ : '';
283
288
  const propFullUrl = propertyKey
284
- ? `${availabilityBaseUrl}/proxy/ari-properties?key=${encodeURIComponent(propertyKey)}`
289
+ ? `${availabilityBaseUrl}/proxy/ari-properties?${propQuery}`
285
290
  : propertyBaseUrl
286
291
  ? `${propertyBaseUrl}/ari/get_properties?id=${encodeURIComponent(propertyId)}`
287
292
  : `/ari/get_properties?id=${encodeURIComponent(propertyId)}`;
@@ -308,7 +313,7 @@ function createBookingApi(config = {}) {
308
313
  };
309
314
 
310
315
  const availabilityPath = propertyKey
311
- ? `/proxy/availability?key=${encodeURIComponent(propertyKey)}`
316
+ ? `/proxy/availability?key=${encodeURIComponent(propertyKey)}${sandbox ? '&sandbox=true' : ''}`
312
317
  : `/api/calendar/booking_engine/${propertyId}/availability`;
313
318
  const data = await request('POST', availabilityPath, body, availabilityBaseUrl);
314
319
  if (data && typeof data === 'object' && (data.error || data.message)) {
@@ -521,14 +526,15 @@ function computeCheckoutTotal(state, nights) {
521
526
  * Use this when integrating Stripe (amount/currency for Payment Intent) and your booking API (external_booking + internal_booking; add stripe_token after payment success).
522
527
  *
523
528
  * @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
524
- * @param {Object} [options] - { propertyId: string|number, propertyKey: string, clientBookingReference?: string }
525
- * @returns {{ stripe: { amount: number, currency: string }, external_booking: Object, internal_booking: Object }}
529
+ * @param {Object} [options] - { propertyId: string|number, propertyKey: string, clientBookingReference?: string, sandbox?: boolean }
530
+ * @returns {{ stripe: { amount: number, currency: string }, external_booking: Object, internal_booking: Object, sandbox?: boolean }}
526
531
  */
527
532
  function buildCheckoutPayload(state, options = {}) {
528
533
  const propertyId = options.propertyId != null ? String(options.propertyId) : '';
529
534
  const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
530
535
  const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
531
536
  const clientBookingReference = options.clientBookingReference || 'nuitee-booking-widget';
537
+ const sandbox = options.sandbox === true;
532
538
 
533
539
  const checkIn = state.checkIn;
534
540
  const checkOut = state.checkOut;
@@ -592,7 +598,7 @@ function buildCheckoutPayload(state, options = {}) {
592
598
  room_id: state.selectedRoom?.id ?? '',
593
599
  };
594
600
 
595
- return {
601
+ const out = {
596
602
  stripe: {
597
603
  amount: total,
598
604
  currency: currency.toLowerCase(),
@@ -600,6 +606,8 @@ function buildCheckoutPayload(state, options = {}) {
600
606
  external_booking,
601
607
  internal_booking,
602
608
  };
609
+ if (sandbox) out.sandbox = true;
610
+ return out;
603
611
  }
604
612
 
605
613
  /**
@@ -608,12 +616,23 @@ function buildCheckoutPayload(state, options = {}) {
608
616
  * Response: { clientSecret, confirmationToken }
609
617
  *
610
618
  * @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
611
- * @param {Object} [options] - { propertyKey: string }
612
- * @returns {{ rate_identifier: string, key: string, metadata: Object }}
619
+ * @param {Object} [options] - { propertyKey: string, sandbox?: boolean }
620
+ * @returns {{ rate_identifier: string, key: string, metadata: Object, sandbox?: boolean }}
613
621
  */
622
+ function generateUUID() {
623
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
624
+ return crypto.randomUUID();
625
+ }
626
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
627
+ const r = Math.random() * 16 | 0;
628
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
629
+ });
630
+ }
631
+
614
632
  function buildPaymentIntentPayload(state, options = {}) {
615
633
  const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
616
634
  const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
635
+ const sandbox = options.sandbox === true;
617
636
  const checkIn = state.checkIn;
618
637
  const checkOut = state.checkOut;
619
638
  const nights = checkIn && checkOut
@@ -633,7 +652,7 @@ function buildPaymentIntentPayload(state, options = {}) {
633
652
  ...(Array.isArray(o.childrenAges) && o.childrenAges.length ? { child_ages: o.childrenAges } : {}),
634
653
  }));
635
654
 
636
- return {
655
+ const out = {
637
656
  rate_identifier: rateIdentifier,
638
657
  key: propertyKey,
639
658
  metadata: {
@@ -649,8 +668,11 @@ function buildPaymentIntentPayload(state, options = {}) {
649
668
  email: g.email ?? '',
650
669
  occupancies: occupanciesForPayload,
651
670
  source_transaction: 'booking engine',
671
+ booking_code: generateUUID(),
652
672
  },
653
673
  };
674
+ if (sandbox) out.sandbox = true;
675
+ return out;
654
676
  }
655
677
 
656
678
  /**
@@ -817,30 +839,86 @@ if (typeof window !== 'undefined') {
817
839
  return `<span class="icon" style="display:inline-flex;align-items:center;justify-content:center;width:${size};height:${size};min-width:${size};min-height:${size};vertical-align:middle;flex-shrink:0;color:inherit;">${sizedIcon}</span>`;
818
840
  }
819
841
 
820
- // Copy the BookingWidget class from core/widget.js
821
- // This is a simplified version that will be bundled
822
- const apiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
823
- const builtInStripeKey = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_STRIPE_KEY__) ? window.__BOOKING_WIDGET_STRIPE_KEY__ : '';
824
- const builtInCreatePaymentIntent = (apiBase && typeof window !== 'undefined')
825
- ? function (payload) {
826
- return fetch(apiBase.replace(/\/$/, '') + '/proxy/create-payment-intent', {
827
- method: 'POST',
828
- headers: { 'Content-Type': 'application/json' },
829
- body: JSON.stringify(payload),
830
- }).then(function (r) {
831
- if (!r.ok) throw new Error(r.statusText || 'Payment intent failed');
832
- return r.json();
833
- }).then(function (data) {
834
- return {
835
- clientSecret: data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret,
836
- confirmationToken: data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token,
837
- };
838
- });
842
+ function deriveWidgetStyles(c) {
843
+ if (!c || (!c.background && !c.text && !c.primary && !c.primaryText)) return {};
844
+ function expandHex(hex) {
845
+ if (!hex || typeof hex !== 'string') return null;
846
+ var h = hex.replace(/^#/, '').trim();
847
+ if (h.length === 3) return '#' + h.split('').map(function (x) { return x + x; }).join('');
848
+ return h.length === 6 ? '#' + h : null;
849
+ }
850
+ function hexToRgb(hex) {
851
+ var x = expandHex(hex);
852
+ if (!x) return null;
853
+ return [parseInt(x.slice(1, 3), 16), parseInt(x.slice(3, 5), 16), parseInt(x.slice(5, 7), 16)];
854
+ }
855
+ function hexToHsl(hex) {
856
+ var x = expandHex(hex);
857
+ if (!x) return null;
858
+ 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;
859
+ var max = Math.max(r, g, b), min = Math.min(r, g, b), h, s, l = (max + min) / 2;
860
+ if (max === min) h = s = 0;
861
+ else {
862
+ var d = max - min;
863
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
864
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
865
+ else if (max === g) h = ((b - r) / d + 2) / 6;
866
+ else h = ((r - g) / d + 4) / 6;
839
867
  }
840
- : null;
868
+ return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
869
+ }
870
+ var bg = c.background || '#1a1a1a', fg = c.text || '#e0e0e0', primary = c.primary || '#3b82f6', primaryFg = c.primaryText || '#ffffff';
871
+ var primaryRgb = hexToRgb(primary), bgHsl = hexToHsl(bg), fgHsl = hexToHsl(fg);
872
+ var styles = { '--primary': primary, '--primary-fg': primaryFg, '--bg': bg, '--fg': fg, '--card-fg': fg };
873
+ if (primaryRgb) styles['--primary-rgb'] = primaryRgb[0] + ', ' + primaryRgb[1] + ', ' + primaryRgb[2];
874
+ styles['--card'] = c.card || (bgHsl ? 'hsl(' + bgHsl[0] + ', ' + bgHsl[1] + '%, ' + Math.min(95, bgHsl[2] + 2) + '%)' : bg);
875
+ if (bgHsl) {
876
+ styles['--secondary'] = 'hsl(' + bgHsl[0] + ', ' + bgHsl[1] + '%, ' + Math.min(95, bgHsl[2] + 10) + '%)';
877
+ styles['--border'] = 'hsl(' + bgHsl[0] + ', ' + bgHsl[1] + '%, ' + Math.min(95, bgHsl[2] + 14) + '%)';
878
+ styles['--input-bg'] = 'hsl(' + bgHsl[0] + ', ' + bgHsl[1] + '%, ' + Math.min(95, bgHsl[2] + 6) + '%)';
879
+ }
880
+ if (fgHsl) {
881
+ styles['--secondary-fg'] = 'hsl(' + fgHsl[0] + ', ' + fgHsl[1] + '%, ' + Math.max(20, fgHsl[2] - 10) + '%)';
882
+ styles['--muted'] = 'hsl(' + fgHsl[0] + ', ' + fgHsl[1] + '%, ' + Math.max(30, fgHsl[2] - 25) + '%)';
883
+ }
884
+ styles['--font-serif'] = "'Playfair Display', Georgia, serif";
885
+ styles['--font-sans'] = "'Inter', system-ui, sans-serif";
886
+ styles['--radius'] = '0.75rem';
887
+ return styles;
888
+ }
889
+
890
+ const builtInStripeKey = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_STRIPE_KEY__) ? window.__BOOKING_WIDGET_STRIPE_KEY__ : '';
891
+
892
+ function escapeHTML(value) {
893
+ const s = String(value ?? '');
894
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
895
+ }
896
+
897
+ // In-memory config cache shared across all instances, keyed by propertyKey.
898
+ const __bwConfigCache = {};
841
899
 
842
900
  class BookingWidget {
843
901
  constructor(options = {}) {
902
+ const defaultApiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
903
+ const builtInCreatePaymentIntent = (!options.createPaymentIntent && defaultApiBase && typeof window !== 'undefined')
904
+ ? function (payload) {
905
+ const url = defaultApiBase.replace(/\/$/, '') + '/proxy/create-payment-intent' + (options.mode === 'sandbox' ? '?sandbox=true' : '');
906
+ return fetch(url, {
907
+ method: 'POST',
908
+ headers: { 'Content-Type': 'application/json' },
909
+ body: JSON.stringify(payload),
910
+ }).then(function (r) {
911
+ if (!r.ok) throw new Error(r.statusText || 'Payment intent failed');
912
+ return r.json();
913
+ }).then(function (data) {
914
+ return {
915
+ clientSecret: data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret,
916
+ confirmationToken: data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token,
917
+ };
918
+ });
919
+ }
920
+ : null;
921
+
844
922
  this.options = {
845
923
  containerId: options.containerId || 'booking-widget-container',
846
924
  onOpen: options.onOpen || null,
@@ -849,20 +927,27 @@ if (typeof window !== 'undefined') {
849
927
  onBeforeConfirm: options.onBeforeConfirm || null,
850
928
  createPaymentIntent: options.createPaymentIntent || builtInCreatePaymentIntent,
851
929
  onBookingComplete: options.onBookingComplete || null,
852
- confirmationBaseUrl: options.confirmationBaseUrl || (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) || 'https://ai.thehotelplanet.com',
930
+ confirmationBaseUrl: options.confirmationBaseUrl || defaultApiBase || 'https://ai.thehotelplanet.com',
853
931
  stripePublishableKey: options.stripePublishableKey || builtInStripeKey || null,
854
932
  propertyId: options.propertyId != null && options.propertyId !== '' ? options.propertyId : null,
855
933
  propertyKey: options.propertyKey || null,
934
+ mode: options.mode || null,
856
935
  bookingApi: options.bookingApi || null,
857
936
  cssUrl: options.cssUrl || null,
858
- // Color customization options
859
- colors: {
860
- background: options.colors?.background || null,
861
- text: options.colors?.text || null,
862
- primary: options.colors?.primary || null,
863
- primaryText: options.colors?.primaryText || null,
864
- ...options.colors
865
- },
937
+ // Color customization: CONFIG defaults from load-config, installer colors override
938
+ colors: (function () {
939
+ const dc = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_DEFAULT_COLORS__) ? window.__BOOKING_WIDGET_DEFAULT_COLORS__ : {};
940
+ const inst = options.colors && typeof options.colors === 'object' ? options.colors : {};
941
+ return {
942
+ background: inst.background ?? dc.background ?? null,
943
+ text: inst.text ?? dc.text ?? null,
944
+ primary: inst.primary ?? dc.primary ?? null,
945
+ primaryText: inst.primaryText ?? dc.primaryText ?? null,
946
+ // When card omitted, use installer's background for consistency
947
+ card: inst.card ?? inst.background ?? dc.card ?? dc.background ?? null,
948
+ ...inst
949
+ };
950
+ })(),
866
951
  ...options
867
952
  };
868
953
 
@@ -902,11 +987,11 @@ if (typeof window !== 'undefined') {
902
987
 
903
988
  this.ROOMS = [];
904
989
  this.RATES = [];
905
- const defaultApiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
906
990
  this.bookingApi = this.options.bookingApi || ((this.options.propertyKey || defaultApiBase) && typeof window.createBookingApi === 'function'
907
991
  ? window.createBookingApi({
908
992
  availabilityBaseUrl: defaultApiBase || '',
909
993
  propertyKey: this.options.propertyKey || undefined,
994
+ mode: this.options.mode === 'sandbox' ? 'sandbox' : undefined,
910
995
  })
911
996
  : null);
912
997
 
@@ -916,6 +1001,15 @@ if (typeof window !== 'undefined') {
916
1001
  this.container = null;
917
1002
  this.overlay = null;
918
1003
  this.widget = null;
1004
+
1005
+ // Store raw installer colors for re-merging after API config fetch.
1006
+ this._rawInstallerColors = options.colors && typeof options.colors === 'object'
1007
+ ? Object.assign({}, options.colors)
1008
+ : {};
1009
+ // Config fetch state: 'idle' | 'loading' | 'loaded' | 'error'
1010
+ this._configState = 'idle';
1011
+ this._configError = null;
1012
+ this._configPromise = null;
919
1013
  }
920
1014
 
921
1015
  getNights() {
@@ -952,8 +1046,13 @@ if (typeof window !== 'undefined') {
952
1046
 
953
1047
  open() {
954
1048
  if (!this.container) this.init();
1049
+ if (!this.overlay || !this.widget) return; // container element not found
955
1050
  this.overlay.classList.add('active');
956
1051
  this.widget.classList.add('active');
1052
+ // Only fetch config when propertyKey is present; missing-key is rendered inside the modal.
1053
+ if (this.options.propertyKey && String(this.options.propertyKey).trim() && this._configState === 'idle') {
1054
+ this._fetchRuntimeConfig();
1055
+ }
957
1056
  this.render();
958
1057
  if (this.options.onOpen) this.options.onOpen();
959
1058
  }
@@ -980,6 +1079,12 @@ if (typeof window !== 'undefined') {
980
1079
  }
981
1080
 
982
1081
  goToStep(step) {
1082
+ if ((step === 'rooms' || step === 'rates') && this._configState !== 'loaded') {
1083
+ if (this._configState === 'loading' && this._configPromise) {
1084
+ this._configPromise.then(() => { if (this._configState === 'loaded') this.goToStep(step); });
1085
+ }
1086
+ return;
1087
+ }
983
1088
  if (step !== 'summary' && step !== 'payment') {
984
1089
  this.checkoutShowPaymentForm = false;
985
1090
  this.paymentElementReady = false;
@@ -1031,7 +1136,9 @@ if (typeof window !== 'undefined') {
1031
1136
  }
1032
1137
 
1033
1138
  this.container = container;
1034
-
1139
+
1140
+ // Always create the overlay and modal so the error is shown inside the dialog,
1141
+ // consistent with the React and Vue behaviour.
1035
1142
  this.overlay = document.createElement('div');
1036
1143
  this.overlay.className = 'booking-widget-overlay';
1037
1144
  this.overlay.addEventListener('click', () => this.close());
@@ -1045,40 +1152,110 @@ if (typeof window !== 'undefined') {
1045
1152
  <div class="booking-widget-step-content"></div>
1046
1153
  `;
1047
1154
  this.widget.querySelector('.booking-widget-close').addEventListener('click', () => this.close());
1155
+ this.widget.querySelector('.booking-widget-step-indicator').addEventListener('click', (e) => {
1156
+ const stepEl = e.target.closest('[data-step]');
1157
+ if (stepEl && stepEl.dataset.step) this.goToStep(stepEl.dataset.step);
1158
+ });
1048
1159
  container.appendChild(this.widget);
1049
1160
 
1050
1161
  if (typeof window !== 'undefined') {
1051
1162
  window.bookingWidgetInstance = this;
1052
1163
  }
1053
1164
 
1054
- // Apply custom colors
1055
1165
  this.applyColors();
1056
-
1057
1166
  this.injectCSS();
1058
1167
  }
1059
1168
 
1060
1169
  applyColors() {
1061
1170
  if (!this.widget) return;
1062
-
1063
1171
  const colors = this.options.colors;
1064
1172
  if (!colors) return;
1065
-
1066
- const style = this.widget.style;
1067
-
1068
- if (colors.background) {
1069
- style.setProperty('--bg', colors.background);
1070
- style.setProperty('--card', colors.background);
1071
- }
1072
- if (colors.text) {
1073
- style.setProperty('--fg', colors.text);
1074
- style.setProperty('--card-fg', colors.text);
1075
- }
1076
- if (colors.primary) {
1077
- style.setProperty('--primary', colors.primary);
1078
- }
1079
- if (colors.primaryText) {
1080
- style.setProperty('--primary-fg', colors.primaryText);
1173
+ const styles = deriveWidgetStyles(colors);
1174
+ const el = this.widget.style;
1175
+ for (var k in styles) if (styles[k] != null && styles[k] !== '') el.setProperty(k, styles[k]);
1176
+ }
1177
+
1178
+ _applyApiColors(apiColors) {
1179
+ const inst = this._rawInstallerColors || {};
1180
+ this.options.colors = {
1181
+ background: inst.background != null ? inst.background : (apiColors.background || null),
1182
+ text: inst.text != null ? inst.text : (apiColors.text || null),
1183
+ primary: inst.primary != null ? inst.primary : (apiColors.primary || null),
1184
+ primaryText: inst.primaryText != null ? inst.primaryText : (apiColors.primaryText || null),
1185
+ card: inst.card != null ? inst.card : (inst.background != null ? inst.background : (apiColors.card || null)),
1186
+ };
1187
+ }
1188
+
1189
+ _fetchRuntimeConfig() {
1190
+ const self = this;
1191
+ const key = String(this.options.propertyKey).trim();
1192
+
1193
+ if (__bwConfigCache[key]) {
1194
+ this._applyApiColors(__bwConfigCache[key]);
1195
+ this._configState = 'loaded';
1196
+ this.applyColors();
1197
+ return Promise.resolve(__bwConfigCache[key]);
1081
1198
  }
1199
+
1200
+ this._configState = 'loading';
1201
+ this.render();
1202
+
1203
+ const url = 'https://ai.thehotelplanet.com/load-config?apikey=' + encodeURIComponent(key) + '&mode=sandbox';
1204
+ this._configPromise = fetch(url)
1205
+ .then(function (res) {
1206
+ if (!res.ok) throw new Error('Failed to load widget configuration (HTTP ' + res.status + ').');
1207
+ return res.json();
1208
+ })
1209
+ .then(function (data) {
1210
+ const apiColors = {};
1211
+ if (data.widgetBackground) apiColors.background = data.widgetBackground;
1212
+ if (data.widgetTextColor) apiColors.text = data.widgetTextColor;
1213
+ if (data.primaryColor) apiColors.primary = data.primaryColor;
1214
+ if (data.buttonTextColor) apiColors.primaryText = data.buttonTextColor;
1215
+ if (data.widgetCardColor) apiColors.card = data.widgetCardColor;
1216
+ __bwConfigCache[key] = apiColors;
1217
+ self._applyApiColors(apiColors);
1218
+ self._configState = 'loaded';
1219
+ self.applyColors();
1220
+ self.render();
1221
+ return apiColors;
1222
+ })
1223
+ .catch(function (err) {
1224
+ self._configError = err.message || 'Failed to load widget configuration.';
1225
+ self._configState = 'error';
1226
+ self.render();
1227
+ });
1228
+
1229
+ return this._configPromise;
1230
+ }
1231
+
1232
+ _retryConfigFetch() {
1233
+ this._configState = 'idle';
1234
+ this._configError = null;
1235
+ this._configPromise = null;
1236
+ this._fetchRuntimeConfig();
1237
+ }
1238
+
1239
+ _configErrorHTML(type, title, body, hint) {
1240
+ 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>`;
1241
+ 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>`;
1242
+ 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>`;
1243
+ const icon = type === 'missing' ? lockIcon : warnIcon;
1244
+ const badgeLabel = type === 'missing'
1245
+ ? `<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`
1246
+ : 'Configuration Error';
1247
+ const retryBtn = type === 'fetch'
1248
+ ? `<button class="booking-widget-config-error__retry" onclick="window.bookingWidgetInstance && window.bookingWidgetInstance._retryConfigFetch()">${retryIcon} Try Again</button>`
1249
+ : '';
1250
+ return `<div class="booking-widget-config-error" role="alert">
1251
+ <div class="booking-widget-config-error__icon-wrap">${icon}</div>
1252
+ <span class="booking-widget-config-error__badge">${badgeLabel}</span>
1253
+ <h3 class="booking-widget-config-error__title">${escapeHTML(title)}</h3>
1254
+ <p class="booking-widget-config-error__desc">${body}</p>
1255
+ <div class="booking-widget-config-error__divider"></div>
1256
+ <p class="booking-widget-config-error__hint">${escapeHTML(hint)}</p>
1257
+ ${retryBtn}
1258
+ </div>`;
1082
1259
  }
1083
1260
 
1084
1261
  injectCSS() {
@@ -1098,12 +1275,20 @@ if (typeof window !== 'undefined') {
1098
1275
 
1099
1276
  render() {
1100
1277
  this.renderStepIndicator();
1278
+ this.renderPropertyKeyMessage();
1101
1279
  this.renderStepContent();
1102
1280
  }
1103
1281
 
1282
+ renderPropertyKeyMessage() {
1283
+ // Config errors are rendered directly inside renderStepContent as full cards.
1284
+ }
1285
+
1104
1286
  renderStepIndicator() {
1105
1287
  const el = this.widget.querySelector('.booking-widget-step-indicator');
1106
- if (this.state.step === 'confirmation') {
1288
+ if (!el) return;
1289
+ const key = this.options.propertyKey;
1290
+ const hasPropertyKey = key != null && key !== '' && (typeof key !== 'string' || key.trim() !== '');
1291
+ if (!hasPropertyKey || this._configState !== 'loaded' || this.state.step === 'confirmation') {
1107
1292
  el.innerHTML = '';
1108
1293
  return;
1109
1294
  }
@@ -1125,6 +1310,40 @@ if (typeof window !== 'undefined') {
1125
1310
 
1126
1311
  renderStepContent() {
1127
1312
  const el = this.widget.querySelector('.booking-widget-step-content');
1313
+ if (!el) return;
1314
+
1315
+ // Missing propertyKey — show error card inside the modal, same as React/Vue.
1316
+ const key = this.options.propertyKey;
1317
+ if (!key || !String(key).trim()) {
1318
+ el.innerHTML = this._configErrorHTML(
1319
+ 'missing',
1320
+ 'Widget Not Configured',
1321
+ 'A <code>propertyKey</code> option is required to initialize this booking widget. Please provide it to load rooms and availability.',
1322
+ 'Contact the site administrator to configure this widget.'
1323
+ );
1324
+ return;
1325
+ }
1326
+
1327
+ if (this._configState === 'loading') {
1328
+ el.innerHTML = `<div class="booking-widget-config-loading">
1329
+ <div class="booking-widget-config-loading__spinner"></div>
1330
+ <span class="booking-widget-config-loading__text">Loading configuration\u2026</span>
1331
+ </div>`;
1332
+ return;
1333
+ }
1334
+ if (this._configState === 'error') {
1335
+ el.innerHTML = this._configErrorHTML(
1336
+ 'fetch',
1337
+ 'Could Not Load Config',
1338
+ escapeHTML(this._configError || 'An unexpected error occurred.'),
1339
+ 'Please try again or contact support.'
1340
+ );
1341
+ return;
1342
+ }
1343
+ if (this._configState !== 'loaded') {
1344
+ el.innerHTML = '';
1345
+ return;
1346
+ }
1128
1347
  switch (this.state.step) {
1129
1348
  case 'dates':
1130
1349
  el.innerHTML = this.renderDatesStep();
@@ -1472,7 +1691,7 @@ if (typeof window !== 'undefined') {
1472
1691
  <span class="rate-name">${[r.policy, ...(r.benefits || [])].filter(Boolean).join(' ')}</span>
1473
1692
  <div class="rate-benefits"><span class="amenity-tag">${r.rate_code ?? r.name}</span></div>
1474
1693
  </div>
1475
- <div class="rate-price"><strong>$ ${total.toLocaleString()}</strong><small>total</small></div>
1694
+ <div class="rate-price"><strong>${(typeof window.formatPrice === 'function' ? window.formatPrice(total, this.state.selectedRoom?.currency) : (this.state.selectedRoom?.currency || 'USD') + ' ' + total.toLocaleString())}</strong><small>total</small></div>
1476
1695
  </div>
1477
1696
  <p class="rate-desc">${r.description}</p>
1478
1697
  </div>`;
@@ -1536,18 +1755,20 @@ if (typeof window !== 'undefined') {
1536
1755
  const roomTotal = Math.round(this.state.selectedRoom.basePrice * this.state.selectedRate.priceModifier * nights * rooms);
1537
1756
  const fees = this.state.selectedRate.fees ?? [];
1538
1757
  const allIncluded = fees.length > 0 && fees.every(f => f.included) && (!this.state.selectedRate.vat || this.state.selectedRate.vat.included);
1758
+ const currency = this.state.selectedRoom?.currency || 'USD';
1759
+ const fmt = (a) => (typeof window.formatPrice === 'function' ? window.formatPrice(a, currency) : currency + ' ' + Math.round(Number(a) || 0).toLocaleString());
1539
1760
  let rows = fees.map(f => {
1540
1761
  const amt = f.perNight ? f.amount * nights * rooms : f.amount;
1541
1762
  const badge = f.included ? '<span class="summary-fee-badge">Included</span>' : '<span class="summary-fee-badge summary-fee-badge--excluded">Excluded</span>';
1542
- return `<div class="summary-row summary-row--fee"><span class="summary-fee-label">${f.name} ${badge}</span><span>$ ` + Math.round(amt).toLocaleString() + `</span></div>`;
1763
+ return `<div class="summary-row summary-row--fee"><span class="summary-fee-label">${f.name} ${badge}</span><span>${fmt(amt)}</span></div>`;
1543
1764
  }).join('');
1544
1765
  const vat = this.state.selectedRate.vat;
1545
1766
  if (vat) {
1546
1767
  const showVatBadge = vat.value !== 0 && vat.value != null;
1547
1768
  const vatBadge = showVatBadge ? (vat.included ? '<span class="summary-fee-badge">Included</span>' : '<span class="summary-fee-badge summary-fee-badge--excluded">Excluded</span>') : '';
1548
- rows += `<div class="summary-row summary-row--fee"><span class="summary-fee-label">VAT${vatBadge ? ' ' + vatBadge : ''}</span><span>$ ` + Math.round(vat.value || 0).toLocaleString() + `</span></div>`;
1769
+ rows += `<div class="summary-row summary-row--fee"><span class="summary-fee-label">VAT${vatBadge ? ' ' + vatBadge : ''}</span><span>${fmt(vat.value || 0)}</span></div>`;
1549
1770
  }
1550
- return `<div class="summary-row"><span>Room total</span><span>$ ` + Math.round(roomTotal).toLocaleString() + `</span></div>
1771
+ return `<div class="summary-row"><span>Room total</span><span>${fmt(roomTotal)}</span></div>
1551
1772
  <div class="summary-fees">
1552
1773
  <p class="summary-fees-heading">Fees &amp; taxes</p>
1553
1774
  ${allIncluded ? '<p class="summary-fees-note">Included in your rate</p>' : ''}
@@ -1556,7 +1777,7 @@ if (typeof window !== 'undefined') {
1556
1777
  })() : ''}
1557
1778
  <div class="summary-total">
1558
1779
  <span class="summary-total-label">Total</span>
1559
- <span class="summary-total-price">$ ${total.toLocaleString()}</span>
1780
+ <span class="summary-total-price">${(typeof window.formatPrice === 'function' ? window.formatPrice(total, this.state.selectedRoom?.currency) : (this.state.selectedRoom?.currency || 'USD') + ' ' + total.toLocaleString())}</span>
1560
1781
  </div>
1561
1782
  ${this.apiError ? `<p style="color:var(--destructive, #ef4444);font-size:0.85em;margin-top:0.5em;">${this.apiError}</p>` : ''}
1562
1783
  <button type="button" class="btn-primary" style="max-width:100%;margin-top:1em;" ${!canSubmit ? 'disabled' : ''} onclick="window.bookingWidgetInstance.confirmReservation(event)">${iconHTML('creditCard', '1.2em')}&nbsp; ${buttonLabel}</button>
@@ -1588,7 +1809,7 @@ if (typeof window !== 'undefined') {
1588
1809
  <h3>Amount due</h3>
1589
1810
  <div class="payment-total-row">
1590
1811
  <span class="payment-total-label">Total</span>
1591
- <span class="payment-total-amount">$ ${total.toLocaleString()}</span>
1812
+ <span class="payment-total-amount">${(typeof window.formatPrice === 'function' ? window.formatPrice(total, this.state.selectedRoom?.currency) : (this.state.selectedRoom?.currency || 'USD') + ' ' + total.toLocaleString())}</span>
1592
1813
  </div>
1593
1814
  <button type="button" class="btn-primary" ${buttonDisabled} onclick="window.bookingWidgetInstance.confirmReservation(event)">${iconHTML('creditCard', '1.2em')}&nbsp; ${buttonLabel}</button>
1594
1815
  <p class="secure-note">${iconHTML('lock', '1em')} Secure & encrypted booking</p>
@@ -1606,7 +1827,8 @@ if (typeof window !== 'undefined') {
1606
1827
  if (this.state.step !== 'payment' || !this.checkoutShowPaymentForm || !this.options.stripePublishableKey || typeof this.options.createPaymentIntent !== 'function') return;
1607
1828
  const buildPaymentIntentPayload = typeof window !== 'undefined' && window.buildPaymentIntentPayload;
1608
1829
  const buildCheckoutPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
1609
- const paymentIntentPayload = buildPaymentIntentPayload ? buildPaymentIntentPayload(this.state, { propertyKey: this.options.propertyKey ?? undefined }) : (buildCheckoutPayload ? buildCheckoutPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null);
1830
+ const sandbox = this.options.mode === 'sandbox';
1831
+ const paymentIntentPayload = buildPaymentIntentPayload ? buildPaymentIntentPayload(this.state, { propertyKey: this.options.propertyKey ?? undefined, sandbox }) : (buildCheckoutPayload ? buildCheckoutPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined, sandbox }) : null);
1610
1832
  if (!paymentIntentPayload) return;
1611
1833
  const self = this;
1612
1834
  this.paymentElementReady = false;
@@ -1685,7 +1907,8 @@ if (typeof window !== 'undefined') {
1685
1907
  const canSubmit = this.state.guest.firstName && this.state.guest.lastName && this.state.guest.email;
1686
1908
  if (!canSubmit) return;
1687
1909
  const buildPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
1688
- const payload = buildPayload ? buildPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null;
1910
+ const sandbox = this.options.mode === 'sandbox';
1911
+ const payload = buildPayload ? buildPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined, sandbox }) : null;
1689
1912
  const hasStripe = this.hasStripe;
1690
1913
 
1691
1914
  if (this.state.step === 'summary' && hasStripe) {
@@ -1745,7 +1968,7 @@ if (typeof window !== 'undefined') {
1745
1968
  const token = String(confirmationToken || '').trim();
1746
1969
  if (!token) throw new Error('Missing confirmation token');
1747
1970
  const base = String(this.options.confirmationBaseUrl || 'https://ai.thehotelplanet.com').replace(/\/$/, '');
1748
- const url = base + '/proxy/confirmation/' + encodeURIComponent(token);
1971
+ const url = base + '/proxy/confirmation/' + encodeURIComponent(token) + (this.options.mode === 'sandbox' ? '?sandbox=true' : '');
1749
1972
  const res = await fetch(url, { method: 'POST' });
1750
1973
  if (!res.ok) throw new Error(await res.text());
1751
1974
  return await res.json();