@nuitee/booking-widget 1.0.0
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/LICENSE +21 -0
- package/README.md +268 -0
- package/USAGE.md +289 -0
- package/dist/booking-widget-standalone.js +1848 -0
- package/dist/booking-widget.css +1711 -0
- package/dist/booking-widget.js +1256 -0
- package/dist/core/booking-api.js +755 -0
- package/dist/core/stripe-config.js +8 -0
- package/dist/core/styles.css +1711 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +5 -0
- package/dist/index.js +5 -0
- package/dist/react/BookingWidget.jsx +1192 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/styles.css +1711 -0
- package/dist/vue/BookingWidget.vue +1062 -0
- package/dist/vue/index.d.ts +1 -0
- package/dist/vue/index.js +3 -0
- package/dist/vue/styles.css +1711 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
(function(){'use strict';window.__BOOKING_WIDGET_API_BASE_URL__='https://ai.thehotelplanet.com';window.__BOOKING_WIDGET_STRIPE_KEY__='pk_test_51T0Se5EvQaQIixshRA8gbsI40NubMGwHlSWZmQM2LAx3jpVO0ThzVifqdTiMeGOyBfLNe8V9G8POGu8SjkvWOStM00H0i1U3uz';})();
|
|
2
|
+
// ===== Icons (Lucide/shadcn) =====
|
|
3
|
+
const icons = {
|
|
4
|
+
calendar: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>`,
|
|
5
|
+
users: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
|
|
6
|
+
user: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
|
|
7
|
+
check: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
|
8
|
+
mapPin: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>`,
|
|
9
|
+
phone: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>`,
|
|
10
|
+
square: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>`,
|
|
11
|
+
creditCard: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect><line x1="1" y1="10" x2="23" y2="10"></line></svg>`,
|
|
12
|
+
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
|
|
13
|
+
star: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
|
|
14
|
+
x: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
|
15
|
+
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
|
|
16
|
+
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function formatPrice(amount, currencyCode = 'USD') {
|
|
20
|
+
if (typeof window !== 'undefined' && window.__bookingWidgetRendering) {
|
|
21
|
+
return '$ ' + (Number(amount) || 0).toLocaleString();
|
|
22
|
+
}
|
|
23
|
+
var custom = typeof window !== 'undefined' && window.formatPrice;
|
|
24
|
+
if (typeof custom === 'function' && custom !== formatPrice) {
|
|
25
|
+
return custom(amount, currencyCode);
|
|
26
|
+
}
|
|
27
|
+
return '$ ' + (Number(amount) || 0).toLocaleString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function iconHTML(name, size = '1em') {
|
|
31
|
+
const icon = icons[name];
|
|
32
|
+
if (!icon) return '';
|
|
33
|
+
const sizeMatch = String(size).match(/([\d.]+)(\w*)/);
|
|
34
|
+
const sizeNum = sizeMatch ? parseFloat(sizeMatch[1]) : 16;
|
|
35
|
+
const sizeUnit = (sizeMatch && sizeMatch[2]) ? sizeMatch[2] : (String(size).includes('em') ? 'em' : 'em');
|
|
36
|
+
const w = sizeNum + sizeUnit;
|
|
37
|
+
const h = sizeNum + sizeUnit;
|
|
38
|
+
let out = icon.replace(/width="[^"]*"/g, 'width="' + w + '"').replace(/height="[^"]*"/g, 'height="' + h + '"');
|
|
39
|
+
if (!out.includes('fill=')) out = out.replace(/<svg/, '<svg fill="none"');
|
|
40
|
+
out = out.replace(/<svg/, '<svg style="display:block;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"');
|
|
41
|
+
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;">' + out + '</span>';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeHTML(value) {
|
|
45
|
+
const s = String(value ?? '');
|
|
46
|
+
return s
|
|
47
|
+
.replace(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'/g, ''');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getDefaultRooms() {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getDefaultRates() {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ===== Core Widget Class =====
|
|
63
|
+
class BookingWidget {
|
|
64
|
+
constructor(options = {}) {
|
|
65
|
+
const apiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
|
|
66
|
+
const builtInStripeKey = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_STRIPE_KEY__) ? window.__BOOKING_WIDGET_STRIPE_KEY__ : '';
|
|
67
|
+
const builtInCreatePaymentIntent = (apiBase && typeof window !== 'undefined')
|
|
68
|
+
? function (payload) {
|
|
69
|
+
return fetch(apiBase.replace(/\/$/, '') + '/proxy/create-payment-intent', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify(payload),
|
|
73
|
+
}).then(function (r) {
|
|
74
|
+
if (!r.ok) throw new Error(r.statusText || 'Payment intent failed');
|
|
75
|
+
return r.json();
|
|
76
|
+
}).then(function (data) {
|
|
77
|
+
return {
|
|
78
|
+
clientSecret: data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret,
|
|
79
|
+
confirmationToken: data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
: null;
|
|
84
|
+
|
|
85
|
+
this.options = {
|
|
86
|
+
containerId: options.containerId || 'booking-widget-container',
|
|
87
|
+
onOpen: options.onOpen || null,
|
|
88
|
+
onClose: options.onClose || null,
|
|
89
|
+
onComplete: options.onComplete || null,
|
|
90
|
+
/** If set, called with checkout payload (Stripe + external_booking + internal_booking) instead of createBooking. Resolve with { confirmationCode } to go to confirmation. */
|
|
91
|
+
onBeforeConfirm: options.onBeforeConfirm || null,
|
|
92
|
+
/** Stripe: optional. When not set, widget uses __BOOKING_WIDGET_API_BASE_URL__ + '/proxy/create-payment-intent' if __BOOKING_WIDGET_STRIPE_KEY__ is set. */
|
|
93
|
+
createPaymentIntent: options.createPaymentIntent || builtInCreatePaymentIntent,
|
|
94
|
+
onBookingComplete: options.onBookingComplete || null,
|
|
95
|
+
stripePublishableKey: options.stripePublishableKey || builtInStripeKey || null,
|
|
96
|
+
// API: pass bookingApi (from createBookingApi) or apiBaseUrl + apiSecret/propertyId etc. to use shared API layer
|
|
97
|
+
bookingApi: options.bookingApi || null,
|
|
98
|
+
apiBaseUrl: options.apiBaseUrl || null,
|
|
99
|
+
apiSecret: options.apiSecret || null,
|
|
100
|
+
propertyId: options.propertyId != null && options.propertyId !== '' ? options.propertyId : null,
|
|
101
|
+
propertyKey: options.propertyKey || null,
|
|
102
|
+
availabilityBaseUrl: options.availabilityBaseUrl || null,
|
|
103
|
+
propertyBaseUrl: options.propertyBaseUrl || null,
|
|
104
|
+
s3BaseUrl: options.s3BaseUrl || null,
|
|
105
|
+
/** Base URL for confirmation status polling (same as API_BASE_URL). Default https://ai.thehotelplanet.com */
|
|
106
|
+
confirmationBaseUrl: options.confirmationBaseUrl || 'https://ai.thehotelplanet.com',
|
|
107
|
+
// Color customization options
|
|
108
|
+
colors: {
|
|
109
|
+
background: options.colors?.background || null,
|
|
110
|
+
text: options.colors?.text || null,
|
|
111
|
+
primary: options.colors?.primary || null,
|
|
112
|
+
primaryText: options.colors?.primaryText || null,
|
|
113
|
+
...options.colors
|
|
114
|
+
},
|
|
115
|
+
...options
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const opts = this.options;
|
|
119
|
+
const defaultApiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
|
|
120
|
+
this.bookingApi = opts.bookingApi || ((opts.apiBaseUrl || opts.availabilityBaseUrl || opts.propertyKey || defaultApiBase) && typeof window.createBookingApi === 'function'
|
|
121
|
+
? window.createBookingApi({
|
|
122
|
+
baseUrl: opts.apiBaseUrl || opts.availabilityBaseUrl || defaultApiBase || '',
|
|
123
|
+
availabilityBaseUrl: opts.availabilityBaseUrl || defaultApiBase || undefined,
|
|
124
|
+
propertyBaseUrl: opts.propertyBaseUrl || undefined,
|
|
125
|
+
s3BaseUrl: opts.s3BaseUrl || undefined,
|
|
126
|
+
propertyId: opts.propertyId != null && opts.propertyId !== '' ? String(opts.propertyId) : undefined,
|
|
127
|
+
propertyKey: opts.propertyKey || undefined,
|
|
128
|
+
headers: opts.apiSecret ? { 'X-API-Key': opts.apiSecret } : undefined,
|
|
129
|
+
})
|
|
130
|
+
: null);
|
|
131
|
+
|
|
132
|
+
this.state = {
|
|
133
|
+
step: 'dates',
|
|
134
|
+
checkIn: null,
|
|
135
|
+
checkOut: null,
|
|
136
|
+
rooms: 1,
|
|
137
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
138
|
+
selectedRoom: null,
|
|
139
|
+
selectedRate: null,
|
|
140
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const baseSteps = [
|
|
144
|
+
{ key: 'dates', label: 'Dates & Guests', num: '01' },
|
|
145
|
+
{ key: 'rooms', label: 'Room', num: '02' },
|
|
146
|
+
{ key: 'rates', label: 'Rate', num: '03' },
|
|
147
|
+
{ key: 'summary', label: 'Summary', num: '04' },
|
|
148
|
+
];
|
|
149
|
+
this.hasStripe = !!(this.options.stripePublishableKey && typeof this.options.createPaymentIntent === 'function');
|
|
150
|
+
this.STEPS = this.hasStripe ? [...baseSteps, { key: 'payment', label: 'Payment', num: '05' }] : baseSteps;
|
|
151
|
+
|
|
152
|
+
this.ROOMS = [];
|
|
153
|
+
this.RATES = [];
|
|
154
|
+
this.loadingRooms = false;
|
|
155
|
+
this.loadingRates = false;
|
|
156
|
+
this.apiError = null;
|
|
157
|
+
this.confirmationCode = null;
|
|
158
|
+
this.confirmationPolling = false;
|
|
159
|
+
this.checkoutShowPaymentForm = false;
|
|
160
|
+
this.paymentElementReady = false;
|
|
161
|
+
this.stripeInstance = null;
|
|
162
|
+
this.elementsInstance = null;
|
|
163
|
+
|
|
164
|
+
this.calendarMonth = null;
|
|
165
|
+
this.calendarYear = null;
|
|
166
|
+
this.pickState = 0;
|
|
167
|
+
this.container = null;
|
|
168
|
+
this.overlay = null;
|
|
169
|
+
this.widget = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ===== Helpers =====
|
|
173
|
+
getNights() {
|
|
174
|
+
if (!this.state.checkIn || !this.state.checkOut) return 0;
|
|
175
|
+
return Math.max(1, Math.round((this.state.checkOut - this.state.checkIn) / 86400000));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getTotalGuests() {
|
|
179
|
+
const occ = this.state.occupancies || [];
|
|
180
|
+
return {
|
|
181
|
+
adults: occ.reduce((s, o) => s + (o.adults || 0), 0),
|
|
182
|
+
children: occ.reduce((s, o) => s + (o.children || 0), 0),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getTotalPrice() {
|
|
187
|
+
if (!this.state.selectedRoom || !this.state.selectedRate) return 0;
|
|
188
|
+
const nights = this.getNights();
|
|
189
|
+
const rooms = this.state.rooms;
|
|
190
|
+
const roomTotal = Math.round(this.state.selectedRoom.basePrice * this.state.selectedRate.priceModifier * nights * rooms);
|
|
191
|
+
const fees = this.state.selectedRate.fees ?? [];
|
|
192
|
+
let add = 0;
|
|
193
|
+
fees.forEach(f => { if (!f.included) add += f.perNight ? f.amount * nights * rooms : f.amount; });
|
|
194
|
+
const vat = this.state.selectedRate.vat;
|
|
195
|
+
if (vat && !vat.included) add += vat.value || 0;
|
|
196
|
+
return Math.round(roomTotal + add);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getFeesTotal() {
|
|
200
|
+
const rate = this.state.selectedRate;
|
|
201
|
+
if (!rate) return 0;
|
|
202
|
+
const nights = this.getNights();
|
|
203
|
+
const rooms = this.state.rooms;
|
|
204
|
+
let total = 0;
|
|
205
|
+
(rate.fees ?? []).forEach(f => { if (!f.included) total += f.perNight ? f.amount * nights * rooms : f.amount; });
|
|
206
|
+
if (rate.vat && !rate.vat.included) total += rate.vat.value || 0;
|
|
207
|
+
return total;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fmt(date) {
|
|
211
|
+
if (!date) return '';
|
|
212
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fmtLong(date) {
|
|
216
|
+
if (!date) return '';
|
|
217
|
+
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
stepIndex(key) {
|
|
221
|
+
return this.STEPS.findIndex(s => s.key === key);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ===== Widget Open/Close =====
|
|
225
|
+
open() {
|
|
226
|
+
if (!this.container) this.init();
|
|
227
|
+
this.overlay.classList.add('active');
|
|
228
|
+
this.widget.classList.add('active');
|
|
229
|
+
this.render();
|
|
230
|
+
if (this.options.onOpen) this.options.onOpen();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
close() {
|
|
234
|
+
if (this.overlay) this.overlay.classList.remove('active');
|
|
235
|
+
if (this.widget) this.widget.classList.remove('active');
|
|
236
|
+
this._confirmationPollCancelled = true;
|
|
237
|
+
this.confirmationPolling = false;
|
|
238
|
+
if (this._confirmationPollTimer) clearTimeout(this._confirmationPollTimer);
|
|
239
|
+
this._confirmationPollTimer = null;
|
|
240
|
+
// Reset state so next open starts from step 1 with no selection
|
|
241
|
+
Object.assign(this.state, {
|
|
242
|
+
step: 'dates',
|
|
243
|
+
checkIn: null,
|
|
244
|
+
checkOut: null,
|
|
245
|
+
rooms: 1,
|
|
246
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
247
|
+
selectedRoom: null,
|
|
248
|
+
selectedRate: null,
|
|
249
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' }
|
|
250
|
+
});
|
|
251
|
+
this.confirmationCode = null;
|
|
252
|
+
this.confirmationToken = null;
|
|
253
|
+
this.confirmationDetails = null;
|
|
254
|
+
this.confirmationStatus = null;
|
|
255
|
+
this.apiError = null;
|
|
256
|
+
if (!this.bookingApi) {
|
|
257
|
+
this.ROOMS = [];
|
|
258
|
+
this.RATES = [];
|
|
259
|
+
}
|
|
260
|
+
if (this.options.onClose) this.options.onClose();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
goToStep(step) {
|
|
264
|
+
const doRender = () => {
|
|
265
|
+
if (step !== 'summary' && step !== 'payment') {
|
|
266
|
+
this.checkoutShowPaymentForm = false;
|
|
267
|
+
this.paymentElementReady = false;
|
|
268
|
+
this.stripeInstance = null;
|
|
269
|
+
if (this.elementsInstance) {
|
|
270
|
+
const el = document.getElementById('booking-widget-payment-element');
|
|
271
|
+
if (el) el.innerHTML = '';
|
|
272
|
+
}
|
|
273
|
+
this.elementsInstance = null;
|
|
274
|
+
}
|
|
275
|
+
if (step === 'payment') this.checkoutShowPaymentForm = true;
|
|
276
|
+
this.state.step = step;
|
|
277
|
+
this.apiError = null;
|
|
278
|
+
if (step === 'rooms' && this.bookingApi) {
|
|
279
|
+
this.loadingRooms = true;
|
|
280
|
+
this.render();
|
|
281
|
+
const occupancies = (this.state.occupancies || []).slice(0, this.state.rooms).map((occ, i) => ({
|
|
282
|
+
adults: occ.adults ?? 1,
|
|
283
|
+
children: (occ.childrenAges || []).slice(0, occ.children || 0).map(a => Number(a) || 0),
|
|
284
|
+
occupancy_index: i + 1,
|
|
285
|
+
}));
|
|
286
|
+
const params = { checkIn: this.state.checkIn, checkOut: this.state.checkOut, rooms: this.state.rooms, occupancies };
|
|
287
|
+
this.bookingApi.fetchRooms(params).then((rooms) => {
|
|
288
|
+
this.ROOMS = Array.isArray(rooms) ? rooms : [];
|
|
289
|
+
this.loadingRooms = false;
|
|
290
|
+
this.render();
|
|
291
|
+
}).catch((err) => {
|
|
292
|
+
this.apiError = err.message || 'Failed to load rooms';
|
|
293
|
+
this.loadingRooms = false;
|
|
294
|
+
this.render();
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (step === 'rates' && this.state.selectedRoom) {
|
|
299
|
+
this.loadingRates = false;
|
|
300
|
+
this.RATES = Array.isArray(this.state.selectedRoom.rates) && this.state.selectedRoom.rates.length
|
|
301
|
+
? this.state.selectedRoom.rates
|
|
302
|
+
: [];
|
|
303
|
+
this.render();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.render();
|
|
307
|
+
};
|
|
308
|
+
if (step === 'rooms' && !this.bookingApi) {
|
|
309
|
+
requestAnimationFrame(() => doRender());
|
|
310
|
+
} else {
|
|
311
|
+
doRender();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ===== Initialization =====
|
|
316
|
+
init() {
|
|
317
|
+
const container = document.getElementById(this.options.containerId);
|
|
318
|
+
if (!container) {
|
|
319
|
+
console.error(`Container with id "${this.options.containerId}" not found`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.container = container;
|
|
324
|
+
|
|
325
|
+
// Create overlay
|
|
326
|
+
this.overlay = document.createElement('div');
|
|
327
|
+
this.overlay.className = 'booking-widget-overlay';
|
|
328
|
+
this.overlay.addEventListener('click', () => this.close());
|
|
329
|
+
container.appendChild(this.overlay);
|
|
330
|
+
|
|
331
|
+
// Create widget modal
|
|
332
|
+
this.widget = document.createElement('div');
|
|
333
|
+
this.widget.className = 'booking-widget-modal';
|
|
334
|
+
this.widget.innerHTML = `
|
|
335
|
+
<button class="booking-widget-close">${iconHTML('x', '1.5em')}</button>
|
|
336
|
+
<div class="booking-widget-step-indicator"></div>
|
|
337
|
+
<div class="booking-widget-step-content"></div>
|
|
338
|
+
`;
|
|
339
|
+
this.widget.querySelector('.booking-widget-close').addEventListener('click', () => this.close());
|
|
340
|
+
this.widget.querySelector('.booking-widget-step-indicator').addEventListener('click', (e) => {
|
|
341
|
+
const stepEl = e.target.closest('[data-step]');
|
|
342
|
+
if (stepEl && stepEl.dataset.step) this.goToStep(stepEl.dataset.step);
|
|
343
|
+
});
|
|
344
|
+
container.appendChild(this.widget);
|
|
345
|
+
|
|
346
|
+
// Apply custom colors
|
|
347
|
+
this.applyColors();
|
|
348
|
+
|
|
349
|
+
// Inject CSS if not already present
|
|
350
|
+
this.injectCSS();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
applyColors() {
|
|
354
|
+
if (!this.widget) return;
|
|
355
|
+
|
|
356
|
+
const colors = this.options.colors;
|
|
357
|
+
if (!colors) return;
|
|
358
|
+
|
|
359
|
+
const style = this.widget.style;
|
|
360
|
+
|
|
361
|
+
if (colors.background) {
|
|
362
|
+
style.setProperty('--bg', colors.background);
|
|
363
|
+
style.setProperty('--card', colors.background);
|
|
364
|
+
}
|
|
365
|
+
if (colors.text) {
|
|
366
|
+
style.setProperty('--fg', colors.text);
|
|
367
|
+
style.setProperty('--card-fg', colors.text);
|
|
368
|
+
}
|
|
369
|
+
if (colors.primary) {
|
|
370
|
+
style.setProperty('--primary', colors.primary);
|
|
371
|
+
}
|
|
372
|
+
if (colors.primaryText) {
|
|
373
|
+
style.setProperty('--primary-fg', colors.primaryText);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
injectCSS() {
|
|
378
|
+
if (document.getElementById('booking-widget-styles')) return;
|
|
379
|
+
|
|
380
|
+
const link = document.createElement('link');
|
|
381
|
+
link.id = 'booking-widget-styles';
|
|
382
|
+
link.rel = 'stylesheet';
|
|
383
|
+
link.href = this.options.cssUrl || './booking-widget.css';
|
|
384
|
+
document.head.appendChild(link);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ===== Render =====
|
|
388
|
+
render() {
|
|
389
|
+
if (!this.widget) return;
|
|
390
|
+
if (this._renderInProgress) return;
|
|
391
|
+
this._renderInProgress = true;
|
|
392
|
+
window.__bookingWidgetRendering = true;
|
|
393
|
+
try {
|
|
394
|
+
this.renderStepIndicator();
|
|
395
|
+
this.renderStepContent();
|
|
396
|
+
} finally {
|
|
397
|
+
window.__bookingWidgetRendering = false;
|
|
398
|
+
this._renderInProgress = false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
renderStepIndicator() {
|
|
403
|
+
const el = this.widget.querySelector('.booking-widget-step-indicator');
|
|
404
|
+
if (!el) return;
|
|
405
|
+
if (this.state.step === 'confirmation') {
|
|
406
|
+
el.innerHTML = '';
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const ci = this.stepIndex(this.state.step);
|
|
410
|
+
let html = '';
|
|
411
|
+
this.STEPS.forEach((s, i) => {
|
|
412
|
+
const cls = i === ci ? 'active' : i < ci ? 'past' : 'future';
|
|
413
|
+
const clickable = cls === 'past' ? ` data-step="${s.key}" role="button" tabindex="0"` : '';
|
|
414
|
+
html += `<div class="step-item">
|
|
415
|
+
<span class="step-circle ${cls}"${clickable}>${i < ci ? iconHTML('check', '0.75em') : s.num}</span>
|
|
416
|
+
<span class="step-label ${cls}"${clickable}>${s.label}</span>`;
|
|
417
|
+
if (i < this.STEPS.length - 1) {
|
|
418
|
+
html += `<span class="step-line"><span class="step-line-fill ${i < ci ? 'filled' : ''}"></span></span>`;
|
|
419
|
+
}
|
|
420
|
+
html += '</div>';
|
|
421
|
+
});
|
|
422
|
+
el.innerHTML = html;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ===== Step Renderers =====
|
|
426
|
+
renderStepContent() {
|
|
427
|
+
const el = this.widget.querySelector('.booking-widget-step-content');
|
|
428
|
+
switch (this.state.step) {
|
|
429
|
+
case 'dates':
|
|
430
|
+
el.innerHTML = this.renderDatesStep();
|
|
431
|
+
this.initCalendar();
|
|
432
|
+
break;
|
|
433
|
+
case 'rooms':
|
|
434
|
+
el.innerHTML = this.renderRoomsStep();
|
|
435
|
+
break;
|
|
436
|
+
case 'rates':
|
|
437
|
+
el.innerHTML = this.renderRatesStep();
|
|
438
|
+
break;
|
|
439
|
+
case 'summary':
|
|
440
|
+
el.innerHTML = this.renderSummaryStep();
|
|
441
|
+
break;
|
|
442
|
+
case 'payment':
|
|
443
|
+
el.innerHTML = this.renderPaymentStep();
|
|
444
|
+
break;
|
|
445
|
+
case 'confirmation':
|
|
446
|
+
el.innerHTML = this.renderConfirmationStep();
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// --- Dates ---
|
|
452
|
+
renderDatesStep() {
|
|
453
|
+
return `
|
|
454
|
+
<h2 class="step-title">Plan Your Stay</h2>
|
|
455
|
+
<p class="step-subtitle">Select your dates and guests to begin</p>
|
|
456
|
+
<div style="max-width:32em;margin:0 auto;">
|
|
457
|
+
<label class="form-label">Dates</label>
|
|
458
|
+
<div class="date-wrapper">
|
|
459
|
+
<div class="date-trigger" onclick="window.bookingWidgetInstance.toggleCalendar()">
|
|
460
|
+
${iconHTML('calendar', '1.2em')}
|
|
461
|
+
<span ${this.state.checkIn ? '' : 'class="placeholder"'}>${this.state.checkIn ? this.fmt(this.state.checkIn) : 'Check-in'}</span>
|
|
462
|
+
→
|
|
463
|
+
<span ${this.state.checkOut ? '' : 'class="placeholder"'}>${this.state.checkOut ? this.fmt(this.state.checkOut) : 'Check-out'}</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="calendar-popup"></div>
|
|
466
|
+
</div>
|
|
467
|
+
<label class="form-label" style="margin-top:1.5em;">${iconHTML('users', '1.2em')} Guests & Rooms</label>
|
|
468
|
+
<div class="guests-rooms-section">
|
|
469
|
+
${(this.state.occupancies || []).slice(0, this.state.rooms).map((occ, roomIdx) => {
|
|
470
|
+
const adults = occ.adults ?? 1;
|
|
471
|
+
const children = occ.children ?? 0;
|
|
472
|
+
const ages = occ.childrenAges || [];
|
|
473
|
+
const childRows = children > 0 ? Array.from({ length: children }, (_, i) => {
|
|
474
|
+
const val = ages[i] != null ? ages[i] : 0;
|
|
475
|
+
let options = '';
|
|
476
|
+
for (let a = 0; a <= 17; a++) options += `<option value="${a}"${val === a ? ' selected' : ''}>${a} year${a !== 1 ? 's' : ''}</option>`;
|
|
477
|
+
return `<div class="child-age-row" style="flex:0 1 calc(50% - 0.5em);min-width:0;margin-top:0.5em;"><label class="form-label" style="font-size:0.75em;color:var(--muted);">Child ${i + 1} age</label><select class="form-input" style="width:100%;margin-top:0.25em;" onchange="window.bookingWidgetInstance.updateChildAge(${roomIdx}, ${i}, parseInt(this.value,10))">${options}</select></div>`;
|
|
478
|
+
}).join('') : '';
|
|
479
|
+
const removeBtn = this.state.rooms > 1 ? `<button type="button" class="remove-room-btn" onclick="window.bookingWidgetInstance.removeRoom(${roomIdx})" aria-label="Remove room">Remove</button>` : '';
|
|
480
|
+
return `<div class="room-card"><div class="room-card-header"><span class="room-card-title">Room ${roomIdx + 1}</span>${removeBtn}</div>${this.counterHTMLForRoom('Adults', adults, 1, 8, roomIdx, 'adults')}${this.counterHTMLForRoom('Children', children, 0, 6, roomIdx, 'children')}${children > 0 ? `<div class="child-ages-section" style="display:flex;flex-wrap:wrap;gap:0.5em 1em;">${childRows}</div>` : ''}</div>`;
|
|
481
|
+
}).join('')}
|
|
482
|
+
<button type="button" class="add-room-btn" ${this.state.rooms >= 5 ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter('rooms',1)">+ Add room</button>
|
|
483
|
+
</div>
|
|
484
|
+
<button class="btn-primary" ${this.state.checkIn && this.state.checkOut ? '' : 'disabled'} onclick="window.bookingWidgetInstance.goToStep('rooms')">Select Room</button>
|
|
485
|
+
</div>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
counterHTML(label, val, min, max, field) {
|
|
489
|
+
return `<div class="counter-row">
|
|
490
|
+
<span class="counter-label">${label}</span>
|
|
491
|
+
<div class="counter-controls">
|
|
492
|
+
<button class="counter-btn" ${val <= min ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter('${field}',-1)">−</button>
|
|
493
|
+
<span class="counter-val">${val}</span>
|
|
494
|
+
<button class="counter-btn" ${val >= max ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter('${field}',1)">+</button>
|
|
495
|
+
</div>
|
|
496
|
+
</div>`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
counterHTMLForRoom(label, val, min, max, roomIndex, field) {
|
|
500
|
+
return `<div class="counter-row">
|
|
501
|
+
<span class="counter-label">${label}</span>
|
|
502
|
+
<div class="counter-controls">
|
|
503
|
+
<button class="counter-btn" ${val <= min ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter('${field}',-1,${roomIndex})">−</button>
|
|
504
|
+
<span class="counter-val">${val}</span>
|
|
505
|
+
<button class="counter-btn" ${val >= max ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter('${field}',1,${roomIndex})">+</button>
|
|
506
|
+
</div>
|
|
507
|
+
</div>`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
changeCounter(field, delta, roomIndex) {
|
|
511
|
+
const limits = { adults: [1,8], children: [0,6], rooms: [1,5] };
|
|
512
|
+
if (field === 'rooms') {
|
|
513
|
+
const next = Math.max(limits.rooms[0], Math.min(limits.rooms[1], this.state.rooms + delta));
|
|
514
|
+
const occ = this.state.occupancies || [{ adults: 2, children: 0, childrenAges: [] }];
|
|
515
|
+
while (occ.length < next) occ.push({ adults: 1, children: 0, childrenAges: [] });
|
|
516
|
+
occ.length = next;
|
|
517
|
+
this.state.occupancies = occ;
|
|
518
|
+
this.state.rooms = next;
|
|
519
|
+
} else {
|
|
520
|
+
const occ = this.state.occupancies || [];
|
|
521
|
+
const idx = roomIndex != null ? roomIndex : 0;
|
|
522
|
+
if (idx < 0 || idx >= occ.length) return;
|
|
523
|
+
const next = Math.max(limits[field][0], Math.min(limits[field][1], (occ[idx][field] || (field === 'adults' ? 1 : 0)) + delta));
|
|
524
|
+
occ[idx][field] = next;
|
|
525
|
+
if (field === 'children') {
|
|
526
|
+
const ages = occ[idx].childrenAges || [];
|
|
527
|
+
while (ages.length < next) ages.push(0);
|
|
528
|
+
ages.length = next;
|
|
529
|
+
occ[idx].childrenAges = ages;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
this.render();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
updateChildAge(roomIndex, childIndex, value) {
|
|
536
|
+
const occ = this.state.occupancies || [];
|
|
537
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
538
|
+
const ages = occ[roomIndex].childrenAges || [];
|
|
539
|
+
const age = Math.max(0, Math.min(17, Number(value) || 0));
|
|
540
|
+
if (childIndex >= 0 && childIndex < ages.length) {
|
|
541
|
+
ages[childIndex] = age;
|
|
542
|
+
this.render();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
removeRoom(roomIndex) {
|
|
547
|
+
if (this.state.rooms <= 1) return;
|
|
548
|
+
const occ = this.state.occupancies || [];
|
|
549
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
550
|
+
occ.splice(roomIndex, 1);
|
|
551
|
+
this.state.occupancies = occ;
|
|
552
|
+
this.state.rooms = occ.length;
|
|
553
|
+
this.render();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- Calendar ---
|
|
557
|
+
initCalendar() {
|
|
558
|
+
const now = new Date();
|
|
559
|
+
this.calendarMonth = now.getMonth();
|
|
560
|
+
this.calendarYear = now.getFullYear();
|
|
561
|
+
// If we have check-in but no check-out, next click sets check-out; otherwise next click sets check-in (allows changing check-in when both are set)
|
|
562
|
+
this.pickState = (this.state.checkIn && !this.state.checkOut) ? 1 : 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
toggleCalendar() {
|
|
566
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
567
|
+
popup.classList.toggle('open');
|
|
568
|
+
if (popup.classList.contains('open')) {
|
|
569
|
+
this.initCalendar();
|
|
570
|
+
this.renderCalendar();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
renderCalendar() {
|
|
575
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
576
|
+
const m1 = this.calendarMonth, y1 = this.calendarYear;
|
|
577
|
+
const m2 = m1 === 11 ? 0 : m1 + 1, y2 = m1 === 11 ? y1 + 1 : y1;
|
|
578
|
+
popup.innerHTML = `
|
|
579
|
+
<div class="cal-header">
|
|
580
|
+
<button onclick="window.bookingWidgetInstance.calNav(-1)">‹</button>
|
|
581
|
+
<span class="cal-title">${this.monthName(m1)} ${y1}</span>
|
|
582
|
+
<span class="cal-title">${this.monthName(m2)} ${y2}</span>
|
|
583
|
+
<button onclick="window.bookingWidgetInstance.calNav(1)">›</button>
|
|
584
|
+
</div>
|
|
585
|
+
<div class="cal-months">${this.buildMonth(y1, m1)}${this.buildMonth(y2, m2)}</div>`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
monthName(m) {
|
|
589
|
+
return ['January','February','March','April','May','June','July','August','September','October','November','December'][m];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
calNav(dir) {
|
|
593
|
+
this.calendarMonth += dir;
|
|
594
|
+
if (this.calendarMonth > 11) { this.calendarMonth = 0; this.calendarYear++; }
|
|
595
|
+
if (this.calendarMonth < 0) { this.calendarMonth = 11; this.calendarYear--; }
|
|
596
|
+
this.renderCalendar();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
buildMonth(year, month) {
|
|
600
|
+
const today = new Date(); today.setHours(0,0,0,0);
|
|
601
|
+
const first = new Date(year, month, 1);
|
|
602
|
+
const days = new Date(year, month + 1, 0).getDate();
|
|
603
|
+
const startDay = first.getDay();
|
|
604
|
+
const names = ['Su','Mo','Tu','We','Th','Fr','Sa'];
|
|
605
|
+
let html = '<div class="cal-grid">';
|
|
606
|
+
names.forEach(n => { html += `<div class="cal-day-name">${n}</div>`; });
|
|
607
|
+
for (let i = 0; i < startDay; i++) html += '<div class="cal-day empty"></div>';
|
|
608
|
+
for (let d = 1; d <= days; d++) {
|
|
609
|
+
const date = new Date(year, month, d);
|
|
610
|
+
const disabled = date < today;
|
|
611
|
+
let cls = 'cal-day';
|
|
612
|
+
if (disabled) cls += ' disabled';
|
|
613
|
+
if (date.getTime() === today.getTime()) cls += ' today';
|
|
614
|
+
if (this.state.checkIn && date.getTime() === this.state.checkIn.getTime()) cls += ' selected';
|
|
615
|
+
if (this.state.checkOut && date.getTime() === this.state.checkOut.getTime()) cls += ' selected';
|
|
616
|
+
if (this.state.checkIn && this.state.checkOut && date > this.state.checkIn && date < this.state.checkOut) cls += ' in-range';
|
|
617
|
+
html += `<button class="${cls}" ${disabled ? 'disabled' : `onclick="window.bookingWidgetInstance.pickDate(${year},${month},${d})"`}>${d}</button>`;
|
|
618
|
+
}
|
|
619
|
+
html += '</div>';
|
|
620
|
+
return html;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
pickDate(y, m, d) {
|
|
624
|
+
const date = new Date(y, m, d);
|
|
625
|
+
if (this.pickState === 0 || (this.state.checkIn && date <= this.state.checkIn)) {
|
|
626
|
+
this.state.checkIn = date; this.state.checkOut = null; this.pickState = 1;
|
|
627
|
+
} else {
|
|
628
|
+
this.state.checkOut = date; this.pickState = 0;
|
|
629
|
+
this.widget.querySelector('.calendar-popup').classList.remove('open');
|
|
630
|
+
}
|
|
631
|
+
this.render();
|
|
632
|
+
if (this.pickState === 1) {
|
|
633
|
+
this.initCalendar();
|
|
634
|
+
this.pickState = 1;
|
|
635
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
636
|
+
if (popup) popup.classList.add('open');
|
|
637
|
+
this.renderCalendar();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// --- Rooms ---
|
|
642
|
+
renderRoomsStep() {
|
|
643
|
+
if (this.loadingRooms) {
|
|
644
|
+
const skeletonCard = `
|
|
645
|
+
<div class="room-skeleton">
|
|
646
|
+
<div class="room-skeleton-img"></div>
|
|
647
|
+
<div class="room-skeleton-body">
|
|
648
|
+
<div class="room-skeleton-line title"></div>
|
|
649
|
+
<div class="room-skeleton-line price"></div>
|
|
650
|
+
<div class="room-skeleton-line desc"></div>
|
|
651
|
+
<div class="room-skeleton-line desc"></div>
|
|
652
|
+
<div class="room-skeleton-line desc"></div>
|
|
653
|
+
<div class="room-skeleton-meta">
|
|
654
|
+
<div class="room-skeleton-line meta"></div>
|
|
655
|
+
<div class="room-skeleton-line meta"></div>
|
|
656
|
+
</div>
|
|
657
|
+
<div class="room-skeleton-tags">
|
|
658
|
+
<div class="room-skeleton-tag"></div>
|
|
659
|
+
<div class="room-skeleton-tag"></div>
|
|
660
|
+
<div class="room-skeleton-tag"></div>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
</div>`;
|
|
664
|
+
const skeletons = Array(4).fill(skeletonCard).join('');
|
|
665
|
+
return `
|
|
666
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
667
|
+
<p class="step-subtitle">Each space is crafted for an unforgettable experience</p>
|
|
668
|
+
<div class="room-grid-wrapper">
|
|
669
|
+
<div class="room-grid">${skeletons}</div>
|
|
670
|
+
</div>`;
|
|
671
|
+
}
|
|
672
|
+
if (this.apiError) {
|
|
673
|
+
return `
|
|
674
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
675
|
+
<p class="step-subtitle" style="color:var(--destructive, #ef4444);">${this.apiError}</p>
|
|
676
|
+
<button class="btn-secondary" onclick="window.bookingWidgetInstance.goToStep('rooms')">Try again</button>`;
|
|
677
|
+
}
|
|
678
|
+
const rooms = Array.isArray(this.ROOMS) ? this.ROOMS : [];
|
|
679
|
+
if (rooms.length === 0) {
|
|
680
|
+
return `
|
|
681
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
682
|
+
<p class="step-subtitle">No rooms available for the selected dates.</p>
|
|
683
|
+
<div class="rooms-empty-state">
|
|
684
|
+
<p class="rooms-empty-message">Try different dates or check back later.</p>
|
|
685
|
+
<button class="btn-secondary" onclick="window.bookingWidgetInstance.goToStep('dates')">Change dates</button>
|
|
686
|
+
</div>`;
|
|
687
|
+
}
|
|
688
|
+
let cards = rooms.map(r => {
|
|
689
|
+
const sel = this.state.selectedRoom?.id === r.id;
|
|
690
|
+
return `<div class="room-card ${sel ? 'selected' : ''}" onclick="window.bookingWidgetInstance.selectRoom('${r.id}')" style="flex: 0 0 280px; min-width: 280px;">
|
|
691
|
+
<div class="room-card-img-wrap">
|
|
692
|
+
<img class="room-card-img" src="${r.image}" alt="${r.name}" onerror="this.src='https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';" />
|
|
693
|
+
${sel ? `<div class="room-card-check">${iconHTML('check', '1em')}</div>` : ''}
|
|
694
|
+
</div>
|
|
695
|
+
<div class="room-card-body">
|
|
696
|
+
<div class="room-card-top">
|
|
697
|
+
<span class="room-card-name">${r.name}</span>
|
|
698
|
+
<div class="room-card-price"><strong>${formatPrice(r.basePrice, r.currency)}</strong><small>/ night</small></div>
|
|
699
|
+
</div>
|
|
700
|
+
<p class="room-card-desc">${r.description}</p>
|
|
701
|
+
<div class="room-card-meta"><span>${iconHTML('square', '0.9em')} ${r.size}</span><span>${iconHTML('user', '0.9em')} Up to ${r.maxGuests}</span></div>
|
|
702
|
+
<div class="amenity-tags">${(r.amenities || []).slice(0, 5).map(a => `<span class="amenity-tag">${a}</span>`).join('')}</div>
|
|
703
|
+
</div>
|
|
704
|
+
</div>`;
|
|
705
|
+
}).join('');
|
|
706
|
+
return `
|
|
707
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
708
|
+
<p class="step-subtitle">Each space is crafted for an unforgettable experience</p>
|
|
709
|
+
<div class="room-grid-wrapper">
|
|
710
|
+
<button class="room-nav-btn room-nav-prev" onclick="window.bookingWidgetInstance.scrollRooms(-1)" aria-label="Previous rooms">${iconHTML('chevronLeft', '1.5em')}</button>
|
|
711
|
+
<div class="room-grid" id="room-grid-${this.options.containerId}">${cards}</div>
|
|
712
|
+
<button class="room-nav-btn room-nav-next" onclick="window.bookingWidgetInstance.scrollRooms(1)" aria-label="Next rooms">${iconHTML('chevronRight', '1.5em')}</button>
|
|
713
|
+
</div>
|
|
714
|
+
<button class="btn-primary" ${this.state.selectedRoom ? '' : 'disabled'} onclick="window.bookingWidgetInstance.goToStep('rates')">Select Rate</button>`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
scrollRooms(direction) {
|
|
718
|
+
const grid = this.widget.querySelector('.room-grid');
|
|
719
|
+
if (!grid) return;
|
|
720
|
+
const scrollAmount = 300; // Scroll by approximately one card width + gap
|
|
721
|
+
grid.scrollBy({
|
|
722
|
+
left: direction * scrollAmount,
|
|
723
|
+
behavior: 'smooth'
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
selectRoom(id) {
|
|
728
|
+
const grid = this.widget.querySelector('.room-grid');
|
|
729
|
+
const scrollLeft = grid ? grid.scrollLeft : 0;
|
|
730
|
+
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
731
|
+
this.state.selectedRoom = this.ROOMS.find(r => r.id == id || String(r.id) === String(id)) || null;
|
|
732
|
+
this.render();
|
|
733
|
+
const restoreScroll = () => {
|
|
734
|
+
const newGrid = this.widget.querySelector('.room-grid');
|
|
735
|
+
if (newGrid && scrollLeft > 0) {
|
|
736
|
+
const prevBehavior = newGrid.style.scrollBehavior;
|
|
737
|
+
newGrid.style.scrollBehavior = 'auto';
|
|
738
|
+
newGrid.scrollLeft = scrollLeft;
|
|
739
|
+
newGrid.style.scrollBehavior = prevBehavior || '';
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
requestAnimationFrame(() => {
|
|
743
|
+
restoreScroll();
|
|
744
|
+
requestAnimationFrame(restoreScroll);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// --- Rates ---
|
|
749
|
+
renderRatesStep() {
|
|
750
|
+
if (this.loadingRates) {
|
|
751
|
+
return `
|
|
752
|
+
<p class="step-subtitle">Loading rates...</p>
|
|
753
|
+
<div style="padding:2em;text-align:center;color:var(--muted);">Please wait.</div>`;
|
|
754
|
+
}
|
|
755
|
+
if (this.apiError && this.state.step === 'rates') {
|
|
756
|
+
return `
|
|
757
|
+
<p class="step-subtitle" style="color:var(--destructive, #ef4444);">${this.apiError}</p>
|
|
758
|
+
<button class="btn-secondary" onclick="window.bookingWidgetInstance.goToStep('rates')">Try again</button>`;
|
|
759
|
+
}
|
|
760
|
+
const nights = this.getNights();
|
|
761
|
+
const base = this.state.selectedRoom?.basePrice ?? 0;
|
|
762
|
+
const rates = Array.isArray(this.RATES) ? this.RATES : [];
|
|
763
|
+
if (rates.length === 0) {
|
|
764
|
+
const room = this.state.selectedRoom;
|
|
765
|
+
const fallbackImg = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';
|
|
766
|
+
const roomSummary = room ? `
|
|
767
|
+
<div class="rate-step-room-summary">
|
|
768
|
+
<img class="rate-step-room-summary-image" src="${room.image || fallbackImg}" alt="" onerror="this.src='${fallbackImg}'" />
|
|
769
|
+
<div class="rate-step-room-summary-body">
|
|
770
|
+
<h3 class="rate-step-room-summary-name">${room.name || ''}</h3>
|
|
771
|
+
${room.description ? `<p class="rate-step-room-summary-desc">${room.description}</p>` : ''}
|
|
772
|
+
<div class="rate-step-room-summary-meta">
|
|
773
|
+
${room.size ? `<span>${room.size}</span>` : ''}
|
|
774
|
+
${room.maxGuests != null ? `<span>Up to ${room.maxGuests} guests</span>` : ''}
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
</div>` : '';
|
|
778
|
+
return `
|
|
779
|
+
<div class="rate-step-card">
|
|
780
|
+
${roomSummary}
|
|
781
|
+
<p style="color:var(--muted);font-size:0.9em;margin:0;padding:1em;">No rates available for this room.</p>
|
|
782
|
+
</div>
|
|
783
|
+
<button class="btn-secondary" onclick="window.bookingWidgetInstance.goToStep('rooms')">Choose another room</button>`;
|
|
784
|
+
}
|
|
785
|
+
let cards = rates.map(r => {
|
|
786
|
+
const sel = this.state.selectedRate?.id === r.id;
|
|
787
|
+
const total = Math.round(base * r.priceModifier * nights * this.state.rooms);
|
|
788
|
+
return `<div class="rate-card ${sel ? 'selected' : ''}" onclick="window.bookingWidgetInstance.selectRate('${r.id}')">
|
|
789
|
+
${r.recommended ? `<div class="rate-badge">${iconHTML('star', '0.9em')} Recommended</div>` : ''}
|
|
790
|
+
<div class="rate-top">
|
|
791
|
+
<div class="rate-top-left">
|
|
792
|
+
<div class="rate-radio"><div class="rate-radio-dot"></div></div>
|
|
793
|
+
<span class="rate-name">${[r.policy, ...(r.benefits || [])].filter(Boolean).join(' ')}</span>
|
|
794
|
+
<div class="rate-benefits"><span class="amenity-tag">${r.rate_code ?? r.name}</span></div>
|
|
795
|
+
</div>
|
|
796
|
+
<div class="rate-price"><strong>${formatPrice(total, this.state.selectedRoom?.currency)}</strong><small>total</small></div>
|
|
797
|
+
</div>
|
|
798
|
+
${r.policy ? `<p class="rate-policy" style="font-size:0.8em;color:var(--muted);margin-top:0.5em;display:flex;align-items:center;gap:0.35em;"><span>${r.policy}</span><span class="policy-info-icon" title="${(r.policyDetail || r.policy).replace(/"/g, '"')}" aria-label="Policy details"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg></span></p>` : ''}
|
|
799
|
+
</div>`;
|
|
800
|
+
}).join('');
|
|
801
|
+
const room = this.state.selectedRoom;
|
|
802
|
+
const fallbackImg = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';
|
|
803
|
+
const roomSummary = room ? `
|
|
804
|
+
<div class="rate-step-room-summary">
|
|
805
|
+
<img class="rate-step-room-summary-image" src="${room.image || fallbackImg}" alt="" onerror="this.src='${fallbackImg}'" />
|
|
806
|
+
<div class="rate-step-room-summary-body">
|
|
807
|
+
<h3 class="rate-step-room-summary-name">${room.name || ''}</h3>
|
|
808
|
+
${room.description ? `<p class="rate-step-room-summary-desc">${room.description}</p>` : ''}
|
|
809
|
+
<div class="rate-step-room-summary-meta">
|
|
810
|
+
${room.size ? `<span>${room.size}</span>` : ''}
|
|
811
|
+
${room.maxGuests != null ? `<span>Up to ${room.maxGuests} guests</span>` : ''}
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
</div>` : '';
|
|
815
|
+
return `
|
|
816
|
+
<div class="rate-step-card">
|
|
817
|
+
${roomSummary}
|
|
818
|
+
<div class="rate-list">${cards}</div>
|
|
819
|
+
</div>
|
|
820
|
+
<button class="btn-primary" ${this.state.selectedRate ? '' : 'disabled'} onclick="window.bookingWidgetInstance.goToStep('summary')">Proceed to Summary</button>`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
selectRate(id) {
|
|
824
|
+
this.state.selectedRate = this.RATES.find(r => r.id == id || String(r.id) === String(id)) || null;
|
|
825
|
+
this.render();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// --- Summary (review + guest info) ---
|
|
829
|
+
renderSummaryStep() {
|
|
830
|
+
const g = this.state.guest;
|
|
831
|
+
const total = this.getTotalPrice();
|
|
832
|
+
const canSubmit = g.firstName && g.lastName && g.email;
|
|
833
|
+
const buttonLabel = this.hasStripe ? 'Proceed to Payment' : 'Confirm Reservation';
|
|
834
|
+
const leftColumn = `<h3 style="font-size:0.65em;text-transform:uppercase;letter-spacing:0.2em;color:var(--muted);padding-bottom:0.5em;border-bottom:1px solid var(--border);margin-bottom:1em;font-family:var(--font-sans);font-weight:500;">Guest Information</h3>
|
|
835
|
+
<div class="form-row">
|
|
836
|
+
<div class="form-group"><label class="form-label">First Name</label><input class="form-input" value="${g.firstName}" oninput="window.bookingWidgetInstance.updateGuest('firstName',this.value)" placeholder="James" /></div>
|
|
837
|
+
<div class="form-group"><label class="form-label">Last Name</label><input class="form-input" value="${g.lastName}" oninput="window.bookingWidgetInstance.updateGuest('lastName',this.value)" placeholder="Bond" /></div>
|
|
838
|
+
</div>
|
|
839
|
+
<div class="form-group"><label class="form-label">Email</label><input class="form-input" type="email" value="${g.email}" oninput="window.bookingWidgetInstance.updateGuest('email',this.value)" placeholder="james@example.com" /></div>
|
|
840
|
+
<div class="form-group"><label class="form-label">Phone</label><input class="form-input" type="tel" value="${g.phone}" oninput="window.bookingWidgetInstance.updateGuest('phone',this.value)" placeholder="+1 (555) 000-0000" /></div>
|
|
841
|
+
<div class="form-group"><label class="form-label">Special Requests</label><textarea class="form-textarea" rows="3" oninput="window.bookingWidgetInstance.updateGuest('specialRequests',this.value)" placeholder="Any special requests...">${g.specialRequests}</textarea></div>`;
|
|
842
|
+
return `
|
|
843
|
+
<h2 class="step-title">Review Your Booking</h2>
|
|
844
|
+
<p class="step-subtitle">Confirm your details before payment</p>
|
|
845
|
+
<div class="checkout-grid">
|
|
846
|
+
<div>${leftColumn}</div>
|
|
847
|
+
<div>
|
|
848
|
+
<div class="summary-box">
|
|
849
|
+
<h3>Booking Summary</h3>
|
|
850
|
+
${this.state.selectedRoom ? `<img src="${this.state.selectedRoom.image}" alt="" style="width:100%;height:8em;object-fit:cover;border-radius:0.5em;margin-bottom:0.75em;" /><p style="font-family:var(--font-serif);margin-bottom:1em;">${this.state.selectedRoom.name}</p>` : ''}
|
|
851
|
+
<div class="summary-row"><span>Check-in</span><span>${this.fmt(this.state.checkIn)}</span></div>
|
|
852
|
+
<div class="summary-row"><span>Check-out</span><span>${this.fmt(this.state.checkOut)}</span></div>
|
|
853
|
+
<div class="summary-row"><span>Guests</span><span>${(() => { const gt = this.getTotalGuests(); return gt.adults + ' adults' + (gt.children ? ', ' + gt.children + ' children' : ''); })()}</span></div>
|
|
854
|
+
<div class="summary-row"><span>Rate</span><span>${this.state.selectedRate ? [this.state.selectedRate.policy, ...(this.state.selectedRate.benefits || [])].filter(Boolean).join(' ') : '—'}</span></div>
|
|
855
|
+
${(this.state.selectedRate?.fees ?? []).length || this.state.selectedRate?.vat ? (() => {
|
|
856
|
+
const nights = this.getNights();
|
|
857
|
+
const rooms = this.state.rooms;
|
|
858
|
+
const roomTotal = Math.round(this.state.selectedRoom.basePrice * this.state.selectedRate.priceModifier * nights * rooms);
|
|
859
|
+
const currency = this.state.selectedRoom?.currency;
|
|
860
|
+
const fees = this.state.selectedRate.fees ?? [];
|
|
861
|
+
const allIncluded = fees.length > 0 && fees.every(f => f.included) && (!this.state.selectedRate.vat || this.state.selectedRate.vat.included);
|
|
862
|
+
let rows = fees.map(f => {
|
|
863
|
+
const amt = f.perNight ? f.amount * nights * rooms : f.amount;
|
|
864
|
+
const badge = f.included ? '<span class="summary-fee-badge">Included</span>' : '<span class="summary-fee-badge summary-fee-badge--excluded">Excluded</span>';
|
|
865
|
+
return `<div class="summary-row summary-row--fee"><span class="summary-fee-label">${f.name} ${badge}</span><span>${formatPrice(amt, currency)}</span></div>`;
|
|
866
|
+
}).join('');
|
|
867
|
+
const vat = this.state.selectedRate.vat;
|
|
868
|
+
if (vat) {
|
|
869
|
+
const vatBadge = (vat.value !== 0 && vat.value != null) ? (vat.included ? '<span class="summary-fee-badge">Included</span>' : '<span class="summary-fee-badge summary-fee-badge--excluded">Excluded</span>') : '';
|
|
870
|
+
rows += `<div class="summary-row summary-row--fee"><span class="summary-fee-label">VAT${vatBadge ? ' ' + vatBadge : ''}</span><span>${formatPrice(vat.value, currency)}</span></div>`;
|
|
871
|
+
}
|
|
872
|
+
return `<div class="summary-row"><span>Room total</span><span>${formatPrice(roomTotal, currency)}</span></div>
|
|
873
|
+
<div class="summary-fees">
|
|
874
|
+
<p class="summary-fees-heading">Fees & taxes</p>
|
|
875
|
+
${allIncluded ? '<p class="summary-fees-note">Included in your rate</p>' : ''}
|
|
876
|
+
<div class="summary-fees-list">${rows}</div>
|
|
877
|
+
</div>`;
|
|
878
|
+
})() : ''}
|
|
879
|
+
<div class="summary-total">
|
|
880
|
+
<span class="summary-total-label">Total</span>
|
|
881
|
+
<span class="summary-total-price">${formatPrice(total, this.state.selectedRoom?.currency)}</span>
|
|
882
|
+
</div>
|
|
883
|
+
${this.apiError ? `<p style="color:var(--destructive, #ef4444);font-size:0.85em;margin-top:0.5em;">${this.apiError}</p>` : ''}
|
|
884
|
+
<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>
|
|
885
|
+
<p class="secure-note">${iconHTML('lock', '1em')} Secure & encrypted booking</p>
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
</div>`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// --- Payment (Stripe form) ---
|
|
892
|
+
renderPaymentStep() {
|
|
893
|
+
const total = this.getTotalPrice();
|
|
894
|
+
const buttonDisabled = !this.paymentElementReady ? 'disabled' : '';
|
|
895
|
+
const buttonLabel = !this.paymentElementReady ? 'Loading payment…' : 'Confirm Reservation';
|
|
896
|
+
return `
|
|
897
|
+
<div class="payment-step">
|
|
898
|
+
<button type="button" class="payment-step-back" onclick="window.bookingWidgetInstance.goToStep('summary')" aria-label="Back to summary">${iconHTML('chevronLeft', '1em')} Back to summary</button>
|
|
899
|
+
<h2 class="step-title">Payment</h2>
|
|
900
|
+
<p class="step-subtitle">Enter your payment details to confirm your reservation</p>
|
|
901
|
+
<div class="checkout-grid">
|
|
902
|
+
<div class="payment-step-form">
|
|
903
|
+
<div class="checkout-payment-section">
|
|
904
|
+
<h3 style="font-size:0.65em;text-transform:uppercase;letter-spacing:0.2em;color:var(--muted);margin-bottom:0.5em;font-family:var(--font-sans);font-weight:500;">Card details</h3>
|
|
905
|
+
${!this.paymentElementReady && !this.apiError ? '<p class="payment-loading-placeholder" style="font-size:0.85em;color:var(--muted);margin:0;">Loading payment form…</p>' : ''}
|
|
906
|
+
<div id="booking-widget-payment-element" class="booking-widget-payment-element"></div>
|
|
907
|
+
${this.apiError ? `<p class="payment-setup-error" style="font-size:0.85em;color:var(--destructive, #ef4444);margin:0;">Payment form could not load. Check the endpoint and try again.</p>` : ''}
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
<div class="payment-step-summary">
|
|
911
|
+
<h3>Amount due</h3>
|
|
912
|
+
<div class="payment-total-row">
|
|
913
|
+
<span class="payment-total-label">Total</span>
|
|
914
|
+
<span class="payment-total-amount">${formatPrice(total, this.state.selectedRoom?.currency)}</span>
|
|
915
|
+
</div>
|
|
916
|
+
<button type="button" class="btn-primary" ${buttonDisabled} onclick="window.bookingWidgetInstance.confirmReservation(event)">${iconHTML('creditCard', '1.2em')} ${buttonLabel}</button>
|
|
917
|
+
<p class="secure-note">${iconHTML('lock', '1em')} Secure & encrypted booking</p>
|
|
918
|
+
</div>
|
|
919
|
+
</div>
|
|
920
|
+
</div>`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
updateGuest(field, value) {
|
|
924
|
+
this.state.guest[field] = value;
|
|
925
|
+
// On summary, do not re-render: replacing step content would destroy inputs and steal focus.
|
|
926
|
+
if (this.state.step === 'summary') {
|
|
927
|
+
this._updateCheckoutSubmitButton();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (this._guestRenderTimer) clearTimeout(this._guestRenderTimer);
|
|
931
|
+
this._guestRenderTimer = setTimeout(() => {
|
|
932
|
+
this._guestRenderTimer = null;
|
|
933
|
+
this.render();
|
|
934
|
+
}, 80);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
_updateCheckoutSubmitButton() {
|
|
938
|
+
if (!this.widget) return;
|
|
939
|
+
const stepEl = this.widget.querySelector('.booking-widget-step-content');
|
|
940
|
+
if (!stepEl) return;
|
|
941
|
+
const btn = stepEl.querySelector('.btn-primary');
|
|
942
|
+
if (!btn) return;
|
|
943
|
+
const g = this.state.guest;
|
|
944
|
+
const canSubmit = !!(g.firstName && g.lastName && g.email);
|
|
945
|
+
btn.disabled = !canSubmit;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
loadStripePaymentElement() {
|
|
949
|
+
if (this.state.step !== 'payment' || !this.checkoutShowPaymentForm || !this.options.stripePublishableKey || typeof this.options.createPaymentIntent !== 'function') return;
|
|
950
|
+
const buildPaymentIntentPayload = typeof window !== 'undefined' && window.buildPaymentIntentPayload;
|
|
951
|
+
const buildCheckoutPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
|
|
952
|
+
const paymentIntentPayload = buildPaymentIntentPayload
|
|
953
|
+
? buildPaymentIntentPayload(this.state, { propertyKey: this.options.propertyKey ?? undefined })
|
|
954
|
+
: (buildCheckoutPayload ? buildCheckoutPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null);
|
|
955
|
+
if (!paymentIntentPayload) return;
|
|
956
|
+
const self = this;
|
|
957
|
+
this.paymentElementReady = false;
|
|
958
|
+
Promise.resolve(this.options.createPaymentIntent(paymentIntentPayload))
|
|
959
|
+
.then((res) => {
|
|
960
|
+
const clientSecret = res?.clientSecret ?? res?.client_secret ?? res?.data?.clientSecret ?? res?.data?.client_secret ?? res?.paymentIntent?.client_secret;
|
|
961
|
+
self.paymentIntentConfirmationToken = res?.confirmationToken ?? res?.confirmation_token ?? res?.data?.confirmationToken ?? res?.data?.confirmation_token;
|
|
962
|
+
if (!clientSecret || self.state.step !== 'payment') {
|
|
963
|
+
self.apiError = 'Payment setup failed: no client secret returned';
|
|
964
|
+
self.render();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const ensureLoadStripe = function () {
|
|
968
|
+
if (typeof window !== 'undefined' && typeof window.loadStripe === 'function') return Promise.resolve(window.loadStripe);
|
|
969
|
+
if (typeof window === 'undefined') return Promise.resolve(null);
|
|
970
|
+
return new Promise(function (resolve) {
|
|
971
|
+
if (window.loadStripe) { resolve(window.loadStripe); return; }
|
|
972
|
+
window.__bookingWidgetStripeReady = function () { resolve(window.loadStripe || null); };
|
|
973
|
+
var s = document.createElement('script');
|
|
974
|
+
s.type = 'module';
|
|
975
|
+
s.textContent = "import('https://esm.sh/@stripe/stripe-js').then(function(m){ window.loadStripe = m.loadStripe; if(window.__bookingWidgetStripeReady) window.__bookingWidgetStripeReady(); });";
|
|
976
|
+
s.onerror = function () { resolve(null); };
|
|
977
|
+
document.head.appendChild(s);
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
return ensureLoadStripe().then(function (loadStripe) {
|
|
981
|
+
if (typeof loadStripe !== 'function') {
|
|
982
|
+
self.apiError = 'Stripe not loaded';
|
|
983
|
+
self.render();
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
return loadStripe(self.options.stripePublishableKey).then((stripe) => {
|
|
987
|
+
if (!stripe || self.state.step !== 'payment') return;
|
|
988
|
+
self.stripeInstance = stripe;
|
|
989
|
+
const elements = stripe.elements({ clientSecret, appearance: { theme: 'flat', variables: { borderRadius: '8px' } } });
|
|
990
|
+
self.elementsInstance = elements;
|
|
991
|
+
const paymentElement = elements.create('payment');
|
|
992
|
+
return new Promise((r) => requestAnimationFrame(r)).then(() => {
|
|
993
|
+
const container = document.getElementById('booking-widget-payment-element');
|
|
994
|
+
if (!container || self.state.step !== 'payment') {
|
|
995
|
+
self.apiError = 'Payment form container not found';
|
|
996
|
+
self.render();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
self.apiError = null;
|
|
1000
|
+
container.innerHTML = '';
|
|
1001
|
+
paymentElement.mount(container);
|
|
1002
|
+
self.paymentElementReady = true;
|
|
1003
|
+
self._updateCheckoutPaymentUI();
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
}).then(function () {});
|
|
1007
|
+
})
|
|
1008
|
+
.catch((err) => {
|
|
1009
|
+
this.apiError = (err && err.message) || err || 'Payment setup failed';
|
|
1010
|
+
this.render();
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
_updateCheckoutPaymentUI() {
|
|
1015
|
+
if (!this.widget) return;
|
|
1016
|
+
const stepEl = this.widget.querySelector('.booking-widget-step-content');
|
|
1017
|
+
if (!stepEl) return;
|
|
1018
|
+
const loadingEl = stepEl.querySelector('.payment-loading-placeholder');
|
|
1019
|
+
if (loadingEl) loadingEl.style.display = 'none';
|
|
1020
|
+
const btn = stepEl.querySelector('.btn-primary');
|
|
1021
|
+
if (btn) {
|
|
1022
|
+
btn.disabled = false;
|
|
1023
|
+
btn.innerHTML = `${iconHTML('creditCard', '1.2em')} Confirm Reservation`;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
confirmReservation(ev) {
|
|
1028
|
+
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
|
|
1029
|
+
if (ev && typeof ev.stopPropagation === 'function') ev.stopPropagation();
|
|
1030
|
+
const canSubmit = this.state.guest.firstName && this.state.guest.lastName && this.state.guest.email;
|
|
1031
|
+
if (!canSubmit) return;
|
|
1032
|
+
const buildPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
|
|
1033
|
+
const payload = buildPayload ? buildPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null;
|
|
1034
|
+
const hasStripe = this.options.stripePublishableKey && typeof this.options.createPaymentIntent === 'function';
|
|
1035
|
+
|
|
1036
|
+
// Summary step with Stripe: go to payment step (form loads there)
|
|
1037
|
+
if (this.state.step === 'summary' && hasStripe) {
|
|
1038
|
+
this.apiError = null;
|
|
1039
|
+
this.goToStep('payment');
|
|
1040
|
+
requestAnimationFrame(() => { requestAnimationFrame(() => this.loadStripePaymentElement()); });
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Payment step: confirm payment then poll confirmation endpoint for booking details
|
|
1045
|
+
if (this.state.step === 'payment' && hasStripe && this.stripeInstance && this.elementsInstance && payload) {
|
|
1046
|
+
this.apiError = null;
|
|
1047
|
+
this.stripeInstance.confirmPayment({
|
|
1048
|
+
elements: this.elementsInstance,
|
|
1049
|
+
confirmParams: { return_url: typeof window !== 'undefined' ? window.location.origin + (window.location.pathname || '') : '' },
|
|
1050
|
+
redirect: 'if_required',
|
|
1051
|
+
}).then(({ error, paymentIntent }) => {
|
|
1052
|
+
if (error) {
|
|
1053
|
+
this.apiError = error && error.message ? error.message : 'Payment failed';
|
|
1054
|
+
this.render();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (paymentIntent && (paymentIntent.status === 'succeeded' || paymentIntent.status === 'requires_capture')) {
|
|
1058
|
+
if (!this.paymentIntentConfirmationToken) {
|
|
1059
|
+
this.apiError = 'Missing confirmation token from payment intent';
|
|
1060
|
+
this.render();
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
this.startConfirmationPolling(this.paymentIntentConfirmationToken);
|
|
1064
|
+
}
|
|
1065
|
+
}).catch((err) => {
|
|
1066
|
+
this.apiError = (err && err.message) || err || 'Payment failed';
|
|
1067
|
+
this.render();
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const onBeforeConfirm = this.options.onBeforeConfirm;
|
|
1073
|
+
if (typeof onBeforeConfirm === 'function' && payload) {
|
|
1074
|
+
this.apiError = null;
|
|
1075
|
+
Promise.resolve(onBeforeConfirm(payload))
|
|
1076
|
+
.then((res) => {
|
|
1077
|
+
const code = res && (res.confirmationCode != null ? res.confirmationCode : res.confirmation_code);
|
|
1078
|
+
this.confirmationCode = code || ('LX' + Date.now().toString(36).toUpperCase().slice(-6));
|
|
1079
|
+
this.state.step = 'confirmation';
|
|
1080
|
+
this.render();
|
|
1081
|
+
})
|
|
1082
|
+
.catch((err) => {
|
|
1083
|
+
this.apiError = (err && err.message) || err || 'Booking failed';
|
|
1084
|
+
this.render();
|
|
1085
|
+
});
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
// No Stripe flow and no onBeforeConfirm: only log payload
|
|
1089
|
+
return;
|
|
1090
|
+
if (this.bookingApi) {
|
|
1091
|
+
this.apiError = null;
|
|
1092
|
+
this.bookingApi.createBooking(this.state).then((res) => {
|
|
1093
|
+
this.confirmationCode = res.confirmationCode || ('LX' + Date.now().toString(36).toUpperCase().slice(-6));
|
|
1094
|
+
this.state.step = 'confirmation';
|
|
1095
|
+
this.render();
|
|
1096
|
+
}).catch((err) => {
|
|
1097
|
+
this.apiError = err.message || 'Booking failed';
|
|
1098
|
+
this.render();
|
|
1099
|
+
});
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
this.confirmationCode = 'LX' + Date.now().toString(36).toUpperCase().slice(-6);
|
|
1103
|
+
this.state.step = 'confirmation';
|
|
1104
|
+
this.render();
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// --- Confirmation ---
|
|
1108
|
+
renderConfirmationStep() {
|
|
1109
|
+
const details = this.confirmationDetails;
|
|
1110
|
+
const showLoader = this.confirmationPolling || !details;
|
|
1111
|
+
if (showLoader) {
|
|
1112
|
+
const status = this.confirmationStatus ? String(this.confirmationStatus) : '';
|
|
1113
|
+
return `
|
|
1114
|
+
<div class="confirmation">
|
|
1115
|
+
<div class="confirmation-loader">
|
|
1116
|
+
<div class="confirmation-loader-spinner"></div>
|
|
1117
|
+
<h2 class="step-title">Finalizing Reservation</h2>
|
|
1118
|
+
<p class="step-subtitle" style="margin:0;color:var(--muted);font-size:0.9em;">We're confirming your booking. This usually takes a few seconds.</p>
|
|
1119
|
+
${status ? `<span style="font-size:0.8em;color:var(--muted);">Status: ${escapeHTML(status)}</span>` : ''}
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>`;
|
|
1122
|
+
}
|
|
1123
|
+
const nights = details.nights != null ? details.nights : this.getNights();
|
|
1124
|
+
const nightsLabel = nights === 1 ? '1 night' : nights + ' nights';
|
|
1125
|
+
const total = (details.totalAmount != null && details.totalAmount !== '') ? Number(details.totalAmount) : this.getTotalPrice();
|
|
1126
|
+
const currency = details.currency || this.state.selectedRoom?.currency || 'USD';
|
|
1127
|
+
const bookingId = details.bookingId ?? details.booking_id ?? null;
|
|
1128
|
+
const hotelName = details.hotelName ?? details.hotel_name ?? null;
|
|
1129
|
+
const roomTypeRaw = details.roomType ?? details.room_type ?? this.state.selectedRoom?.name ?? '';
|
|
1130
|
+
const roomType = roomTypeRaw.replace(/\s*Rate\s*\d+\s*$/i, '').trim();
|
|
1131
|
+
const checkInD = details.checkIn ? (typeof details.checkIn === 'string' ? new Date(details.checkIn) : details.checkIn) : this.state.checkIn;
|
|
1132
|
+
const checkOutD = details.checkOut ? (typeof details.checkOut === 'string' ? new Date(details.checkOut) : details.checkOut) : this.state.checkOut;
|
|
1133
|
+
const checkInStr = checkInD ? this.fmtLong(checkInD) : '';
|
|
1134
|
+
const checkOutStr = checkOutD ? this.fmtLong(checkOutD) : '';
|
|
1135
|
+
const bookingIdBlock = bookingId ? `<div class="confirm-header"><div class="confirm-booking-id"><span class="confirm-booking-id-label">Booking ID</span><span class="confirm-booking-id-value">${escapeHTML(bookingId)}</span></div></div>` : '';
|
|
1136
|
+
const hotelRow = hotelName ? `<div class="confirm-detail"><span class="confirm-detail-icon">${iconHTML('mapPin', '1.2em')}</span><div class="confirm-detail-content"><span>${escapeHTML(hotelName)}</span></div></div>` : '';
|
|
1137
|
+
return `
|
|
1138
|
+
<div class="confirmation">
|
|
1139
|
+
<div class="confirm-icon">${iconHTML('check', '3.5em')}</div>
|
|
1140
|
+
<h2 class="step-title">Reservation Confirmed</h2>
|
|
1141
|
+
<p class="step-subtitle">Thank you, ${escapeHTML(this.state.guest.firstName)}. We look forward to welcoming you.</p>
|
|
1142
|
+
<div class="confirm-box">
|
|
1143
|
+
${bookingIdBlock}
|
|
1144
|
+
<div class="confirm-detail">
|
|
1145
|
+
<span class="confirm-detail-icon">${iconHTML('calendar', '1.2em')}</span>
|
|
1146
|
+
<div class="confirm-detail-content">
|
|
1147
|
+
<span>${checkInStr} to ${checkOutStr}</span>
|
|
1148
|
+
<small>${escapeHTML(nightsLabel)}</small>
|
|
1149
|
+
</div>
|
|
1150
|
+
</div>
|
|
1151
|
+
${hotelRow}
|
|
1152
|
+
<div class="confirm-detail">
|
|
1153
|
+
<span class="confirm-detail-icon">${iconHTML('mapPin', '1.2em')}</span>
|
|
1154
|
+
<div class="confirm-detail-content">
|
|
1155
|
+
<span>${escapeHTML(roomType)}</span>
|
|
1156
|
+
${this.state.selectedRate ? `<span class="confirm-detail-rate-line">${escapeHTML([this.state.selectedRate.policy, ...(this.state.selectedRate.benefits || [])].filter(Boolean).join(' · '))}</span>` : ''}
|
|
1157
|
+
</div>
|
|
1158
|
+
</div>
|
|
1159
|
+
<div class="confirm-detail">
|
|
1160
|
+
<span class="confirm-detail-icon">${iconHTML('phone', '1.2em')}</span>
|
|
1161
|
+
<div class="confirm-detail-content">
|
|
1162
|
+
<span>${escapeHTML(this.state.guest.firstName + ' ' + this.state.guest.lastName)}</span>
|
|
1163
|
+
<small>${escapeHTML(this.state.guest.email)}</small>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
<div class="summary-total">
|
|
1167
|
+
<span class="summary-total-label">Total Charged</span>
|
|
1168
|
+
<span class="summary-total-price">${formatPrice(total, currency)}</span>
|
|
1169
|
+
</div>
|
|
1170
|
+
</div>
|
|
1171
|
+
<p style="font-size:0.75em;color:var(--muted);margin-bottom:1.5em;">A confirmation email has been sent to ${escapeHTML(this.state.guest.email)}</p>
|
|
1172
|
+
<button class="btn-secondary" onclick="window.bookingWidgetInstance.resetBooking()">Book Another Stay</button>
|
|
1173
|
+
</div>`;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async fetchConfirmationDetails(confirmationToken) {
|
|
1177
|
+
const token = String(confirmationToken || '').trim();
|
|
1178
|
+
if (!token) throw new Error('Missing confirmation token');
|
|
1179
|
+
const base = String(this.options.confirmationBaseUrl || 'https://ai.thehotelplanet.com').replace(/\/$/, '');
|
|
1180
|
+
const url = `${base}/proxy/confirmation/${encodeURIComponent(token)}`;
|
|
1181
|
+
const res = await fetch(url, { method: 'POST' });
|
|
1182
|
+
if (!res.ok) throw new Error(await res.text());
|
|
1183
|
+
return await res.json();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
startConfirmationPolling(confirmationToken) {
|
|
1187
|
+
this.confirmationToken = String(confirmationToken || '').trim();
|
|
1188
|
+
this.confirmationDetails = null;
|
|
1189
|
+
this.confirmationStatus = 'pending';
|
|
1190
|
+
this.confirmationPolling = true;
|
|
1191
|
+
this.apiError = null;
|
|
1192
|
+
this.state.step = 'confirmation';
|
|
1193
|
+
this.render();
|
|
1194
|
+
|
|
1195
|
+
if (this._confirmationPollTimer) clearTimeout(this._confirmationPollTimer);
|
|
1196
|
+
this._confirmationPollCancelled = false;
|
|
1197
|
+
const pollOnce = async () => {
|
|
1198
|
+
if (this._confirmationPollCancelled) return;
|
|
1199
|
+
try {
|
|
1200
|
+
const data = await this.fetchConfirmationDetails(this.confirmationToken);
|
|
1201
|
+
const status = String(data?.status ?? data?.booking_status ?? data?.state ?? '').toLowerCase();
|
|
1202
|
+
this.confirmationStatus = status || this.confirmationStatus || 'pending';
|
|
1203
|
+
if (status === 'confirmed') {
|
|
1204
|
+
this.confirmationDetails = data;
|
|
1205
|
+
this.confirmationPolling = false;
|
|
1206
|
+
this.render();
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
// Keep polling; surface the latest error unobtrusively.
|
|
1211
|
+
this.apiError = (err && err.message) || err || 'Failed to fetch confirmation';
|
|
1212
|
+
this.render();
|
|
1213
|
+
}
|
|
1214
|
+
this._confirmationPollTimer = setTimeout(pollOnce, 2000);
|
|
1215
|
+
};
|
|
1216
|
+
pollOnce();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
resetBooking() {
|
|
1220
|
+
this._confirmationPollCancelled = true;
|
|
1221
|
+
this.confirmationPolling = false;
|
|
1222
|
+
if (this._confirmationPollTimer) clearTimeout(this._confirmationPollTimer);
|
|
1223
|
+
this._confirmationPollTimer = null;
|
|
1224
|
+
Object.assign(this.state, {
|
|
1225
|
+
step: 'dates',
|
|
1226
|
+
checkIn: null,
|
|
1227
|
+
checkOut: null,
|
|
1228
|
+
rooms: 1,
|
|
1229
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
1230
|
+
selectedRoom: null,
|
|
1231
|
+
selectedRate: null,
|
|
1232
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' }
|
|
1233
|
+
});
|
|
1234
|
+
this.confirmationCode = null;
|
|
1235
|
+
this.confirmationToken = null;
|
|
1236
|
+
this.confirmationDetails = null;
|
|
1237
|
+
this.confirmationStatus = null;
|
|
1238
|
+
this.apiError = null;
|
|
1239
|
+
if (!this.bookingApi) {
|
|
1240
|
+
this.ROOMS = [];
|
|
1241
|
+
this.RATES = [];
|
|
1242
|
+
}
|
|
1243
|
+
if (this.options.onComplete) {
|
|
1244
|
+
this.options.onComplete(this.state);
|
|
1245
|
+
}
|
|
1246
|
+
this.render();
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Export for different module systems
|
|
1251
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1252
|
+
module.exports = BookingWidget;
|
|
1253
|
+
}
|
|
1254
|
+
if (typeof window !== 'undefined') {
|
|
1255
|
+
window.BookingWidget = BookingWidget;
|
|
1256
|
+
}
|