@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 +3 -3
- package/dist/booking-widget-standalone.js +292 -69
- package/dist/booking-widget.css +141 -2
- package/dist/booking-widget.js +246 -38
- package/dist/core/booking-api.js +30 -8
- package/dist/core/color-utils.js +103 -0
- package/dist/core/stripe-config.js +2 -0
- package/dist/core/styles.css +141 -2
- package/dist/react/BookingWidget.jsx +111 -36
- package/dist/react/styles.css +141 -2
- package/dist/utils/config-service.js +99 -0
- package/dist/vue/BookingWidget.vue +96 -24
- package/dist/vue/styles.css +141 -2
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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 ||
|
|
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
|
|
859
|
-
colors: {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
|
|
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 (
|
|
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>$
|
|
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>$
|
|
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>$
|
|
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>$
|
|
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 & 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">$
|
|
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')} ${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">$
|
|
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')} ${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
|
|
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
|
|
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();
|