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