@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,1848 @@
|
|
|
1
|
+
(function(){'use strict';window.__BOOKING_WIDGET_API_BASE_URL__='https://ai.thehotelplanet.com';window.__BOOKING_WIDGET_STRIPE_KEY__='pk_test_51T0Se5EvQaQIixshRA8gbsI40NubMGwHlSWZmQM2LAx3jpVO0ThzVifqdTiMeGOyBfLNe8V9G8POGu8SjkvWOStM00H0i1U3uz';})();
|
|
2
|
+
/**
|
|
3
|
+
* Shared Booking API layer for the @nuitee/booking-widget.
|
|
4
|
+
* Used by Vanilla JS, Vue, and React variants so integration lives in one place.
|
|
5
|
+
*
|
|
6
|
+
* API contract (adapt your backend to this shape or use transform in the methods below):
|
|
7
|
+
*
|
|
8
|
+
* GET /rooms?checkIn=ISO&checkOut=ISO&adults=N&children=N&rooms=N
|
|
9
|
+
* Response: Array<{ id, name, description, image, size, maxGuests, amenities[], basePrice }>
|
|
10
|
+
*
|
|
11
|
+
* GET /rates?roomId=ID&checkIn=ISO&checkOut=ISO&adults=N&children=N&rooms=N
|
|
12
|
+
* Response: Array<{ id, name, description, priceModifier, benefits[], recommended? }>
|
|
13
|
+
*
|
|
14
|
+
* POST /bookings
|
|
15
|
+
* Body: { checkIn, checkOut, adults, children, rooms, roomId, rateId, guest: { firstName, lastName, email, phone, specialRequests } }
|
|
16
|
+
* Response: { confirmationCode [, ...] }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_ROOMS = [];
|
|
20
|
+
|
|
21
|
+
const DEFAULT_RATES = [];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns date as YYYY-MM-DD using local date (no timezone shift).
|
|
25
|
+
* Calendar dates are created at local midnight; using toISOString() would convert to UTC and can shift the day.
|
|
26
|
+
*/
|
|
27
|
+
function toISO(date) {
|
|
28
|
+
if (!date) return '';
|
|
29
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
30
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
31
|
+
const y = d.getFullYear();
|
|
32
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
33
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
34
|
+
return `${y}-${m}-${day}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Maps currency code (e.g. 'EUR') to symbol (e.g. '€'). Falls back to code if unknown. */
|
|
38
|
+
const CURRENCY_SYMBOLS = {
|
|
39
|
+
USD: '$', EUR: '€', GBP: '£', JPY: '¥', CHF: 'Fr', CAD: 'C$', AUD: 'A$',
|
|
40
|
+
MAD: 'MAD', IDR: 'Rp', INR: '₹', THB: '฿', MXN: 'MX$', BRL: 'R$',
|
|
41
|
+
CNY: '¥', KRW: '₩', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', CZK: 'Kč',
|
|
42
|
+
HUF: 'Ft', RON: 'lei', BGN: 'лв', TRY: '₺', ZAR: 'R', AED: 'د.إ', SAR: '﷼',
|
|
43
|
+
};
|
|
44
|
+
function getCurrencySymbol(code) {
|
|
45
|
+
if (!code || typeof code !== 'string') return '$';
|
|
46
|
+
const c = code.trim().toUpperCase();
|
|
47
|
+
return CURRENCY_SYMBOLS[c] ?? c + ' ';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Format price with currency symbol and space (e.g. formatPrice(1234, 'EUR') → '€ 1,234') */
|
|
51
|
+
function formatPrice(amount, currencyCode = 'USD') {
|
|
52
|
+
const symbol = getCurrencySymbol(currencyCode).replace(/\s+$/, '');
|
|
53
|
+
const n = Number(amount);
|
|
54
|
+
if (Number.isNaN(n)) return symbol + ' 0';
|
|
55
|
+
return symbol + ' ' + Math.round(n).toLocaleString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Extract user-facing error message from API response body (error, message, etc.) */
|
|
59
|
+
function getErrorMessage(body, fallback) {
|
|
60
|
+
if (body && typeof body === 'object') {
|
|
61
|
+
const msg = body.error ?? body.message ?? body.err ?? body.detail;
|
|
62
|
+
if (typeof msg === 'string') return msg;
|
|
63
|
+
if (typeof msg === 'object' && msg?.message) return msg.message;
|
|
64
|
+
}
|
|
65
|
+
return fallback || 'Request failed';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parses date from availability API (e.g. "2023-05-20 23:59:59"). Normalizes to ISO for reliable parsing.
|
|
70
|
+
*/
|
|
71
|
+
function parsePolicyUntil(val) {
|
|
72
|
+
if (val == null) return null;
|
|
73
|
+
if (val instanceof Date) return Number.isNaN(val.getTime()) ? null : val;
|
|
74
|
+
const str = String(val).trim();
|
|
75
|
+
if (!str) return null;
|
|
76
|
+
const iso = str.replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)/, '$1T$2');
|
|
77
|
+
const d = new Date(iso);
|
|
78
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Derives "Free cancellation" vs "Non-refundable" from the availability rate only.
|
|
83
|
+
* Uses only the policy array from the availability response: policy[] with { until: "YYYY-MM-DD HH:mm:ss", penalty }.
|
|
84
|
+
* If policy.length > 0 and the first until date is in the future → "Free cancellation", else "Non-refundable".
|
|
85
|
+
*/
|
|
86
|
+
function deriveRatePolicyLabel(avRate) {
|
|
87
|
+
const policy = avRate.policy;
|
|
88
|
+
if (!Array.isArray(policy) || policy.length === 0) return 'Non-refundable';
|
|
89
|
+
|
|
90
|
+
const first = policy[0];
|
|
91
|
+
const until = first?.until;
|
|
92
|
+
if (until == null) return 'Non-refundable';
|
|
93
|
+
|
|
94
|
+
const untilDate = parsePolicyUntil(until);
|
|
95
|
+
if (untilDate == null) return 'Non-refundable';
|
|
96
|
+
|
|
97
|
+
return untilDate > new Date() ? 'Free cancellation' : 'Non-refundable';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a stable key for "board" (meal plan) from rate benefits for grouping.
|
|
102
|
+
* @param {Array<string>} [benefits]
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
function getBoardKey(benefits) {
|
|
106
|
+
if (!Array.isArray(benefits) || benefits.length === 0) return '';
|
|
107
|
+
return benefits
|
|
108
|
+
.map((b) => (typeof b === 'string' ? b : String(b)).trim())
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.sort()
|
|
111
|
+
.join('|');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* From a list of rates (widget shape with policy, benefits, priceModifier), keep only the cheapest
|
|
116
|
+
* rate per (cancellation policy, board). So e.g. 4 rates → 2 free cancellation + room only, 2 non-refundable + breakfast
|
|
117
|
+
* becomes 2 rates: cheapest in each group.
|
|
118
|
+
* @param {Array<{ policy?: string, benefits?: string[], priceModifier?: number }>} rates
|
|
119
|
+
* @returns {Array} Filtered rates (same objects, no copy)
|
|
120
|
+
*/
|
|
121
|
+
function filterCheapestRatePerPolicyAndBoard(rates) {
|
|
122
|
+
if (!Array.isArray(rates) || rates.length === 0) return rates;
|
|
123
|
+
const byKey = new Map();
|
|
124
|
+
for (const r of rates) {
|
|
125
|
+
const policy = r.policy ?? 'Non-refundable';
|
|
126
|
+
const boardKey = getBoardKey(r.benefits);
|
|
127
|
+
const key = `${String(policy)}::${boardKey}`;
|
|
128
|
+
const existing = byKey.get(key);
|
|
129
|
+
const price = r.priceModifier ?? 1;
|
|
130
|
+
if (!existing || (existing.priceModifier ?? 1) > price) {
|
|
131
|
+
byKey.set(key, r);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return Array.from(byKey.values());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Normalize rate fees from availability API into a uniform shape for the widget.
|
|
139
|
+
* API shape: [{ name, value, included? }, ...]. included: true = fee already in rate (show only); false = add to total.
|
|
140
|
+
* @param {Object} avRate - Raw rate from availability response
|
|
141
|
+
* @returns {Array<{ name: string, amount: number, included?: boolean }>}
|
|
142
|
+
*/
|
|
143
|
+
function normalizeRateFees(avRate) {
|
|
144
|
+
const list = avRate?.fees ?? (avRate?.fee != null ? [avRate.fee] : []);
|
|
145
|
+
if (!Array.isArray(list) || list.length === 0) return [];
|
|
146
|
+
return list
|
|
147
|
+
.map((f) => {
|
|
148
|
+
const name = f?.name ?? f?.label ?? f?.type ?? f?.description ?? 'Fee';
|
|
149
|
+
const amount = Number(f?.value ?? f?.amount ?? f?.price ?? 0) || 0;
|
|
150
|
+
if (amount <= 0) return null;
|
|
151
|
+
const included = Boolean(f?.included);
|
|
152
|
+
return { name: String(name), amount, included };
|
|
153
|
+
})
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Format policy "until" (e.g. "2023-05-20 23:59:59") for display in tooltip. */
|
|
158
|
+
function formatPolicyUntilForDisplay(untilStr) {
|
|
159
|
+
const d = parsePolicyUntil(untilStr);
|
|
160
|
+
if (!d) return '';
|
|
161
|
+
const datePart = d.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
162
|
+
const timePart = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
163
|
+
return `${datePart}, ${timePart}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Builds human-readable policy detail for tooltip from availability rate policy array.
|
|
168
|
+
*/
|
|
169
|
+
const NON_REFUNDABLE_POLICY_DETAIL = 'In case of cancellation, no-show or modification, the total amount of the booking is not refunded.';
|
|
170
|
+
|
|
171
|
+
function deriveRatePolicyDetail(avRate) {
|
|
172
|
+
const policy = avRate.policy;
|
|
173
|
+
if (!Array.isArray(policy) || policy.length === 0) {
|
|
174
|
+
return NON_REFUNDABLE_POLICY_DETAIL;
|
|
175
|
+
}
|
|
176
|
+
const first = policy[0];
|
|
177
|
+
const until = first?.until;
|
|
178
|
+
const penalty = first?.penalty;
|
|
179
|
+
const untilDate = until ? parsePolicyUntil(until) : null;
|
|
180
|
+
const isFree = untilDate != null && untilDate > new Date();
|
|
181
|
+
const formattedUntil = formatPolicyUntilForDisplay(until);
|
|
182
|
+
if (isFree && formattedUntil) {
|
|
183
|
+
const penaltyNote = penalty != null && penalty > 0
|
|
184
|
+
? ` After that, a penalty of ${penalty} may apply.`
|
|
185
|
+
: ' After that, a penalty may apply.';
|
|
186
|
+
return `Free cancellation until ${formattedUntil}.${penaltyNote}`;
|
|
187
|
+
}
|
|
188
|
+
return NON_REFUNDABLE_POLICY_DETAIL;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Creates a booking API client. Use this in all three widgets (Vanilla, Vue, React).
|
|
193
|
+
*
|
|
194
|
+
* @param {Object} config
|
|
195
|
+
* @param {string} [config.baseUrl] - Base URL for the API (e.g. 'https://api.example.com'). If omitted, fetch methods return static fallback data.
|
|
196
|
+
* @param {string} [config.availabilityBaseUrl] - Base URL for availability API (e.g. 'https://extranet.thehotelplanet.com'). Defaults to baseUrl.
|
|
197
|
+
* @param {string} [config.propertyBaseUrl] - Base URL for property/rooms API (e.g. 'https://thehotelplanet.com'). Use '' in dev for same-origin proxy.
|
|
198
|
+
* @param {string} [config.s3BaseUrl] - Base URL for room images (e.g. from VITE_AWS_S3_PATH).
|
|
199
|
+
* @param {string|number} [config.propertyId] - Property ID (legacy). Prefer propertyKey for proxy endpoints.
|
|
200
|
+
* @param {string} [config.propertyKey] - Property key for proxy endpoints (e.g. VITE_PROPERTY_KEY). Used in /proxy/availability and /proxy/ari-properties?key=.
|
|
201
|
+
* @param {string} [config.currency] - Currency code (e.g. 'MAD'). Default 'MAD'.
|
|
202
|
+
* @param {Object} [config.headers] - Optional. Static headers for each request (e.g. { 'X-API-Key': '...' }).
|
|
203
|
+
* @param {function(): Object} [config.getHeaders] - Optional. Return headers for each request (merged after headers). Use for dynamic headers.
|
|
204
|
+
* @returns {{ fetchRooms: function, fetchRates: function, createBooking: function, hasApi: boolean }}
|
|
205
|
+
*/
|
|
206
|
+
function createBookingApi(config = {}) {
|
|
207
|
+
const baseUrl = (config.baseUrl || '').replace(/\/$/, '');
|
|
208
|
+
// Empty string means "same-origin" (relative path) for proxy in dev; undefined/null fall back to baseUrl
|
|
209
|
+
const availabilityBaseUrl = config.availabilityBaseUrl === '' ? '' : ((config.availabilityBaseUrl || baseUrl) || '').replace(/\/$/, '');
|
|
210
|
+
const propertyBaseUrl = (config.propertyBaseUrl ?? '').replace(/\/$/, '');
|
|
211
|
+
const s3BaseUrl = (config.s3BaseUrl ?? '').replace(/\/$/, '');
|
|
212
|
+
const propertyId = config.propertyId != null ? String(config.propertyId) : null;
|
|
213
|
+
const rawKey = config.propertyKey != null && config.propertyKey !== '' ? String(config.propertyKey).trim() : null;
|
|
214
|
+
// Proxy endpoints expect key to start with "book_engine_" (installer's VITE_PROPERTY_KEY). Ignore other values.
|
|
215
|
+
const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : null;
|
|
216
|
+
if (rawKey && !propertyKey && typeof console !== 'undefined' && console.warn) {
|
|
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).');
|
|
218
|
+
}
|
|
219
|
+
const currency = config.currency || 'MAD';
|
|
220
|
+
const staticHeaders = config.headers || {};
|
|
221
|
+
const getHeaders = config.getHeaders || (() => ({}));
|
|
222
|
+
|
|
223
|
+
// baseUrl can be empty string for same-origin; treat propertyKey flow as "API enabled"
|
|
224
|
+
const hasApi = Boolean(baseUrl) || Boolean(propertyKey);
|
|
225
|
+
const hasAvailabilityApi = Boolean(propertyKey || propertyId);
|
|
226
|
+
|
|
227
|
+
async function request(method, path, body, base = baseUrl) {
|
|
228
|
+
const url = `${(base || '').replace(/\/$/, '')}${path}`;
|
|
229
|
+
const headers = {
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
...staticHeaders,
|
|
232
|
+
...getHeaders(),
|
|
233
|
+
};
|
|
234
|
+
if (url.includes('ngrok')) headers['ngrok-skip-browser-warning'] = 'true';
|
|
235
|
+
const options = { method, headers };
|
|
236
|
+
if (body && (method === 'POST' || method === 'PUT')) options.body = JSON.stringify(body);
|
|
237
|
+
const res = await fetch(url, options);
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
let body;
|
|
240
|
+
try { body = await res.json(); } catch (_) {}
|
|
241
|
+
const err = new Error(getErrorMessage(body, res.statusText || 'Request failed'));
|
|
242
|
+
err.status = res.status;
|
|
243
|
+
err.response = res;
|
|
244
|
+
err.body = body;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
const contentType = res.headers.get('content-type');
|
|
248
|
+
if (contentType && contentType.includes('application/json')) return res.json();
|
|
249
|
+
return res.text();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Fetches available rooms: calls availability API, applies occupancy filter, merges with property/rooms from get_properties.
|
|
254
|
+
* @param {{ checkIn: Date|string, checkOut: Date|string, adults: number, children: number, rooms: number }} params
|
|
255
|
+
* @returns {Promise<Array>} Room list in widget shape { id, name, description, image, size, maxGuests, amenities, basePrice }
|
|
256
|
+
*/
|
|
257
|
+
async function fetchRooms(params) {
|
|
258
|
+
if (!hasAvailabilityApi) return Promise.resolve([]);
|
|
259
|
+
const rooms = params.rooms ?? 1;
|
|
260
|
+
let occupancies;
|
|
261
|
+
if (Array.isArray(params.occupancies) && params.occupancies.length >= rooms) {
|
|
262
|
+
occupancies = params.occupancies.slice(0, rooms).map((occ, i) => ({
|
|
263
|
+
adults: Math.max(1, Math.min(8, Number(occ.adults) || 1)),
|
|
264
|
+
children: Array.isArray(occ.children) ? occ.children.slice(0, 6).map(a => Math.max(0, Math.min(17, Number(a) || 0))) : [],
|
|
265
|
+
occupancy_index: i + 1,
|
|
266
|
+
}));
|
|
267
|
+
} else {
|
|
268
|
+
const adults = params.adults ?? 2;
|
|
269
|
+
const children = params.children ?? 0;
|
|
270
|
+
const childrenAges = Array.isArray(params.childrenAges) ? params.childrenAges.slice(0, children) : [];
|
|
271
|
+
while (childrenAges.length < children) childrenAges.push(0);
|
|
272
|
+
occupancies = Array.from({ length: rooms }, (_, i) => ({
|
|
273
|
+
adults: i === 0 ? adults : 1,
|
|
274
|
+
children: i === 0 ? childrenAges : [],
|
|
275
|
+
occupancy_index: i + 1,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
const occupancyLength = occupancies.length;
|
|
279
|
+
|
|
280
|
+
// Fetch get_properties first to get currency_code (e.g. 'EUR') and room details
|
|
281
|
+
let propertyRooms = {};
|
|
282
|
+
let propertyCurrency = currency;
|
|
283
|
+
const propFullUrl = propertyKey
|
|
284
|
+
? `${availabilityBaseUrl}/proxy/ari-properties?key=${encodeURIComponent(propertyKey)}`
|
|
285
|
+
: propertyBaseUrl
|
|
286
|
+
? `${propertyBaseUrl}/ari/get_properties?id=${encodeURIComponent(propertyId)}`
|
|
287
|
+
: `/ari/get_properties?id=${encodeURIComponent(propertyId)}`;
|
|
288
|
+
try {
|
|
289
|
+
const getOpts = { method: 'GET' };
|
|
290
|
+
if (propFullUrl.includes('ngrok')) getOpts.headers = { 'ngrok-skip-browser-warning': 'true' };
|
|
291
|
+
const propRes = await fetch(propFullUrl, getOpts);
|
|
292
|
+
if (propRes.ok) {
|
|
293
|
+
const propData = await propRes.json();
|
|
294
|
+
propertyRooms = propData?.rooms ?? {};
|
|
295
|
+
propertyCurrency = (typeof propData?.currency_code === 'string' && propData.currency_code.trim())
|
|
296
|
+
? propData.currency_code.trim()
|
|
297
|
+
: currency;
|
|
298
|
+
}
|
|
299
|
+
} catch (_) {}
|
|
300
|
+
|
|
301
|
+
const body = {
|
|
302
|
+
...(propertyKey ? {} : { property_ids: [Number(propertyId)] }),
|
|
303
|
+
checkin: toISO(params.checkIn),
|
|
304
|
+
checkout: toISO(params.checkOut),
|
|
305
|
+
occupancies,
|
|
306
|
+
nationality: '',
|
|
307
|
+
currency: propertyCurrency,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const availabilityPath = propertyKey
|
|
311
|
+
? `/proxy/availability?key=${encodeURIComponent(propertyKey)}`
|
|
312
|
+
: `/api/calendar/booking_engine/${propertyId}/availability`;
|
|
313
|
+
const data = await request('POST', availabilityPath, body, availabilityBaseUrl);
|
|
314
|
+
if (data && typeof data === 'object' && (data.error || data.message)) {
|
|
315
|
+
throw new Error(getErrorMessage(data, 'Failed to load rooms'));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Extract rates: properties[0].rates or rates or data
|
|
319
|
+
const rawRates = data?.properties?.[0]?.rates ?? data?.rates ?? data?.data ?? [];
|
|
320
|
+
const ratesArray = Array.isArray(rawRates) ? rawRates : Object.values(rawRates || {});
|
|
321
|
+
|
|
322
|
+
if (ratesArray.length === 0) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Occupancy filter: keep only room_id+rate_id combos that appear for ALL occupancies
|
|
327
|
+
const duplicateCountMap = new Map();
|
|
328
|
+
for (const r of ratesArray) {
|
|
329
|
+
const key = `${r.room_id}-${r.rate_id}`;
|
|
330
|
+
duplicateCountMap.set(key, (duplicateCountMap.get(key) || 0) + 1);
|
|
331
|
+
}
|
|
332
|
+
const uniqueRates = ratesArray.filter((r) => {
|
|
333
|
+
const key = `${r.room_id}-${r.rate_id}`;
|
|
334
|
+
return duplicateCountMap.get(key) === occupancyLength;
|
|
335
|
+
});
|
|
336
|
+
const seenKeys = new Set();
|
|
337
|
+
const filteredRates = uniqueRates.filter((r) => {
|
|
338
|
+
const key = `${r.room_id}-${r.rate_id}`;
|
|
339
|
+
if (seenKeys.has(key)) return false;
|
|
340
|
+
seenKeys.add(key);
|
|
341
|
+
return true;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const availableRoomIds = [...new Set(filteredRates.map((r) => String(r.room_id)))];
|
|
345
|
+
if (availableRoomIds.length === 0) return [];
|
|
346
|
+
|
|
347
|
+
// Map to widget room format (propertyRooms already fetched above)
|
|
348
|
+
const getPrice = (r) => {
|
|
349
|
+
const a = r?.amount ?? r;
|
|
350
|
+
return (a?.price ?? a?.total ?? r?.price ?? 0) || 0;
|
|
351
|
+
};
|
|
352
|
+
const fallbackImage = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';
|
|
353
|
+
|
|
354
|
+
return availableRoomIds.map((roomId) => {
|
|
355
|
+
const roomData = propertyRooms[roomId] ?? propertyRooms[String(roomId)] ?? {};
|
|
356
|
+
const roomRates = filteredRates.filter((r) => String(r.room_id) === String(roomId));
|
|
357
|
+
const minPrice = roomRates.length
|
|
358
|
+
? Math.min(...roomRates.map(getPrice).filter((p) => p > 0)) || roomData.base_price || 0
|
|
359
|
+
: roomData.base_price || 0;
|
|
360
|
+
|
|
361
|
+
const photos = roomData.photos ?? [];
|
|
362
|
+
const mainPhoto = photos.find((p) => p.main) ?? photos[0];
|
|
363
|
+
const photoPath = mainPhoto?.path ?? '';
|
|
364
|
+
const image = s3BaseUrl && photoPath ? `${s3BaseUrl}/${photoPath.replace(/^\//, '')}` : fallbackImage;
|
|
365
|
+
|
|
366
|
+
const sizeVal = roomData.size?.value ?? roomData.size_value;
|
|
367
|
+
let sizeUnit = roomData.size?.unit ?? roomData.size_unit ?? 'm²';
|
|
368
|
+
if (typeof sizeUnit === 'string' && /^square_?meter(s)?$/i.test(sizeUnit.trim())) sizeUnit = 'm²';
|
|
369
|
+
const size = sizeVal != null ? `${sizeVal} ${sizeUnit}` : '';
|
|
370
|
+
|
|
371
|
+
const roomBasePrice = minPrice || 1;
|
|
372
|
+
const propertyRates = roomData.rates ?? {};
|
|
373
|
+
const rates = roomRates.map((avRate, idx) => {
|
|
374
|
+
const ratePrice = getPrice(avRate) || 0;
|
|
375
|
+
const plan = propertyRates[avRate.rate_id] ?? propertyRates[String(avRate.rate_id)] ?? {};
|
|
376
|
+
const planName = plan.name ?? avRate.name ?? `Rate ${avRate.rate_id}`;
|
|
377
|
+
const board = avRate.board ?? avRate.meal_plan;
|
|
378
|
+
const benefits = Array.isArray(board) ? board : (board ? [String(board)] : []);
|
|
379
|
+
const policyLabel = deriveRatePolicyLabel(avRate);
|
|
380
|
+
const policyDetail = deriveRatePolicyDetail(avRate);
|
|
381
|
+
const fees = normalizeRateFees(avRate);
|
|
382
|
+
const vat = avRate?.vat != null
|
|
383
|
+
? { value: Number(avRate.vat.value) || 0, included: Boolean(avRate.vat.included) }
|
|
384
|
+
: { value: 0, included: false };
|
|
385
|
+
const rateCode = avRate.rate_code ?? avRate.rate_name ?? plan.rate_code ?? plan.rate_name ?? planName;
|
|
386
|
+
const rateIdentifier = avRate.rate_identifier ?? avRate.rate_id ?? avRate.id ?? rateCode;
|
|
387
|
+
return {
|
|
388
|
+
id: avRate.rate_id ?? avRate.id ?? avRate,
|
|
389
|
+
name: planName,
|
|
390
|
+
description: plan.min_stay_length ? `Min. ${plan.min_stay_length} night(s)` : '',
|
|
391
|
+
priceModifier: ratePrice > 0 ? ratePrice / roomBasePrice : 1,
|
|
392
|
+
benefits,
|
|
393
|
+
policy: policyLabel,
|
|
394
|
+
policyDetail,
|
|
395
|
+
fees,
|
|
396
|
+
vat,
|
|
397
|
+
recommended: idx === 0,
|
|
398
|
+
rate_code: rateCode,
|
|
399
|
+
rate_identifier: rateIdentifier,
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const ratesFiltered = filterCheapestRatePerPolicyAndBoard(rates);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
id: roomData.id ?? roomId,
|
|
407
|
+
name: roomData.name ?? `Room ${roomId}`,
|
|
408
|
+
description: roomData.description ?? '',
|
|
409
|
+
image,
|
|
410
|
+
size,
|
|
411
|
+
maxGuests: roomData.max_guests ?? roomData.max_occupancy ?? 2,
|
|
412
|
+
amenities: (() => {
|
|
413
|
+
const a = roomData.amenities;
|
|
414
|
+
if (!a) return [];
|
|
415
|
+
if (Array.isArray(a)) return a.map((x) => (typeof x === 'object' && x?.name ? x.name : String(x)));
|
|
416
|
+
return [String(a)];
|
|
417
|
+
})(),
|
|
418
|
+
basePrice: minPrice || 0,
|
|
419
|
+
currency: propertyCurrency,
|
|
420
|
+
rates: ratesFiltered,
|
|
421
|
+
};
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {string} roomId
|
|
427
|
+
* @param {{ checkIn: Date|string, checkOut: Date|string, adults: number, children: number, rooms: number }} params
|
|
428
|
+
* @returns {Promise<Array>} Rate list in widget shape
|
|
429
|
+
*/
|
|
430
|
+
async function fetchRates(roomId, params) {
|
|
431
|
+
if (!hasApi) return Promise.resolve([]);
|
|
432
|
+
let adults = params.adults;
|
|
433
|
+
let children = params.children;
|
|
434
|
+
if (Array.isArray(params.occupancies) && params.occupancies.length > 0) {
|
|
435
|
+
adults = params.occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
436
|
+
children = params.occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
437
|
+
}
|
|
438
|
+
const q = new URLSearchParams({
|
|
439
|
+
roomId,
|
|
440
|
+
checkIn: toISO(params.checkIn),
|
|
441
|
+
checkOut: toISO(params.checkOut),
|
|
442
|
+
adults: String(adults ?? 2),
|
|
443
|
+
children: String(children ?? 0),
|
|
444
|
+
rooms: String(params.rooms ?? 1),
|
|
445
|
+
}).toString();
|
|
446
|
+
const data = await request('GET', `/rates?${q}`);
|
|
447
|
+
if (data && typeof data === 'object' && (data.error || data.message)) {
|
|
448
|
+
throw new Error(getErrorMessage(data, 'Failed to load rates'));
|
|
449
|
+
}
|
|
450
|
+
return Array.isArray(data) ? data : (data.rates || data.data || []);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @param {Object} payload - Full booking state: checkIn, checkOut, adults, children, rooms, selectedRoom (or roomId), selectedRate (or rateId), guest
|
|
455
|
+
* @returns {Promise<{ confirmationCode: string }>}
|
|
456
|
+
*/
|
|
457
|
+
async function createBooking(payload) {
|
|
458
|
+
let adults = payload.adults;
|
|
459
|
+
let children = payload.children;
|
|
460
|
+
if (Array.isArray(payload.occupancies) && payload.occupancies.length > 0) {
|
|
461
|
+
adults = payload.occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
462
|
+
children = payload.occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
463
|
+
}
|
|
464
|
+
const body = {
|
|
465
|
+
checkIn: toISO(payload.checkIn),
|
|
466
|
+
checkOut: toISO(payload.checkOut),
|
|
467
|
+
adults: adults ?? 2,
|
|
468
|
+
children: children ?? 0,
|
|
469
|
+
rooms: payload.rooms ?? 1,
|
|
470
|
+
roomId: payload.selectedRoom?.id ?? payload.roomId,
|
|
471
|
+
rateId: payload.selectedRate?.id ?? payload.rateId,
|
|
472
|
+
guest: payload.guest || {},
|
|
473
|
+
};
|
|
474
|
+
if (!hasApi) {
|
|
475
|
+
return Promise.resolve({
|
|
476
|
+
confirmationCode: 'LX' + Date.now().toString(36).toUpperCase().slice(-6),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const data = await request('POST', '/bookings', body);
|
|
480
|
+
if (data && typeof data === 'object' && (data.error || data.message)) {
|
|
481
|
+
throw new Error(getErrorMessage(data, 'Booking failed'));
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
confirmationCode: data.confirmationCode || data.confirmation_code || data.id || ('LX' + Date.now().toString(36).toUpperCase().slice(-6)),
|
|
485
|
+
...data,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
fetchRooms,
|
|
491
|
+
fetchRates,
|
|
492
|
+
createBooking,
|
|
493
|
+
hasApi,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Compute total price (same logic as widget getTotalPrice).
|
|
499
|
+
* API basePrice is total for the full stay per room; total = basePrice * priceModifier * rooms.
|
|
500
|
+
* @param {{ selectedRoom: { basePrice: number, currency?: string }, selectedRate: { priceModifier: number, fees?: Array<{ included?: boolean, perNight?: boolean, amount: number }>, vat?: { included?: boolean, value?: number } }, rooms: number }} state
|
|
501
|
+
* @param {number} nights
|
|
502
|
+
* @returns {number}
|
|
503
|
+
*/
|
|
504
|
+
function computeCheckoutTotal(state, nights) {
|
|
505
|
+
const room = state.selectedRoom;
|
|
506
|
+
const rate = state.selectedRate;
|
|
507
|
+
if (!room || !rate) return 0;
|
|
508
|
+
const rooms = state.rooms ?? 1;
|
|
509
|
+
const roomTotal = Math.round(room.basePrice * rate.priceModifier * rooms);
|
|
510
|
+
const fees = rate.fees ?? [];
|
|
511
|
+
let add = 0;
|
|
512
|
+
fees.forEach((f) => {
|
|
513
|
+
if (!f.included) add += f.perNight ? f.amount * nights * rooms : f.amount;
|
|
514
|
+
});
|
|
515
|
+
if (rate.vat && !rate.vat.included) add += rate.vat.value || 0;
|
|
516
|
+
return Math.round(roomTotal + add);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Build payload for checkout submit: Stripe Payment Intent + external_booking + internal_booking.
|
|
521
|
+
* Use this when integrating Stripe (amount/currency for Payment Intent) and your booking API (external_booking + internal_booking; add stripe_token after payment success).
|
|
522
|
+
*
|
|
523
|
+
* @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
|
|
524
|
+
* @param {Object} [options] - { propertyId: string|number, propertyKey: string, clientBookingReference?: string }
|
|
525
|
+
* @returns {{ stripe: { amount: number, currency: string }, external_booking: Object, internal_booking: Object }}
|
|
526
|
+
*/
|
|
527
|
+
function buildCheckoutPayload(state, options = {}) {
|
|
528
|
+
const propertyId = options.propertyId != null ? String(options.propertyId) : '';
|
|
529
|
+
const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
|
|
530
|
+
const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
|
|
531
|
+
const clientBookingReference = options.clientBookingReference || 'nuitee-booking-widget';
|
|
532
|
+
|
|
533
|
+
const checkIn = state.checkIn;
|
|
534
|
+
const checkOut = state.checkOut;
|
|
535
|
+
const nights = checkIn && checkOut
|
|
536
|
+
? Math.max(1, Math.round((checkOut - checkIn) / 86400000))
|
|
537
|
+
: 0;
|
|
538
|
+
const total = computeCheckoutTotal(state, nights);
|
|
539
|
+
const currency = (state.selectedRoom?.currency || 'USD').trim();
|
|
540
|
+
const occupancies = Array.isArray(state.occupancies) ? state.occupancies : [{ adults: 2, children: 0, childrenAges: [] }];
|
|
541
|
+
const totalAdults = occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
542
|
+
const totalChildren = occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
543
|
+
|
|
544
|
+
const g = state.guest || {};
|
|
545
|
+
const availabilityRequest = {
|
|
546
|
+
...(propertyKey ? { property_key: propertyKey } : propertyId ? { property_id: propertyId } : {}),
|
|
547
|
+
checkin: toISO(checkIn),
|
|
548
|
+
checkout: toISO(checkOut),
|
|
549
|
+
currency: currency.toUpperCase(),
|
|
550
|
+
occupancies: occupancies.map((o, i) => ({
|
|
551
|
+
adults: o.adults ?? 0,
|
|
552
|
+
children: o.children ?? 0,
|
|
553
|
+
...(Array.isArray(o.childrenAges) && o.childrenAges.length ? { child_ages: o.childrenAges } : {}),
|
|
554
|
+
...(i > 0 ? { occupancy_index: i + 1 } : {}),
|
|
555
|
+
})),
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const rateIdentifier = state.selectedRate?.rate_identifier ?? state.selectedRate?.rate_code ?? state.selectedRate?.id;
|
|
559
|
+
|
|
560
|
+
const external_booking = {
|
|
561
|
+
availability_request: availabilityRequest,
|
|
562
|
+
booked_rates: [
|
|
563
|
+
{
|
|
564
|
+
quantity: state.rooms ?? 1,
|
|
565
|
+
rate_identifier: rateIdentifier,
|
|
566
|
+
amount: { price: total, currency: currency.toUpperCase() },
|
|
567
|
+
guest_details: [
|
|
568
|
+
{ leader: true, first_name: g.firstName || '', last_name: g.lastName || '' },
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
client_booking_reference: clientBookingReference,
|
|
573
|
+
};
|
|
574
|
+
if (g.specialRequests != null && String(g.specialRequests).trim() !== '') {
|
|
575
|
+
external_booking.customer_remarks = String(g.specialRequests).trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const internal_booking = {
|
|
579
|
+
email: g.email || '',
|
|
580
|
+
first_name: g.firstName || '',
|
|
581
|
+
last_name: g.lastName || '',
|
|
582
|
+
phone: g.phone || '',
|
|
583
|
+
checkin: toISO(checkIn),
|
|
584
|
+
checkout: toISO(checkOut),
|
|
585
|
+
stays: nights,
|
|
586
|
+
price: total,
|
|
587
|
+
currency: currency.toUpperCase(),
|
|
588
|
+
points_for_currency_amount: options.pointsForCurrencyAmount ?? 2,
|
|
589
|
+
adults: totalAdults,
|
|
590
|
+
children: totalChildren,
|
|
591
|
+
room_name: state.selectedRoom?.name || '',
|
|
592
|
+
room_id: state.selectedRoom?.id ?? '',
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
stripe: {
|
|
597
|
+
amount: total,
|
|
598
|
+
currency: currency.toLowerCase(),
|
|
599
|
+
},
|
|
600
|
+
external_booking,
|
|
601
|
+
internal_booking,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Build payload for payment-intent API (Stripe).
|
|
607
|
+
* Request body: { rate_identifier, key, metadata: { hotel_name, room_type, check_in, check_out, guests, nights, currency, first_name, last_name } }
|
|
608
|
+
* Response: { clientSecret, confirmationToken }
|
|
609
|
+
*
|
|
610
|
+
* @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
|
|
611
|
+
* @param {Object} [options] - { propertyKey: string }
|
|
612
|
+
* @returns {{ rate_identifier: string, key: string, metadata: Object }}
|
|
613
|
+
*/
|
|
614
|
+
function buildPaymentIntentPayload(state, options = {}) {
|
|
615
|
+
const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
|
|
616
|
+
const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
|
|
617
|
+
const checkIn = state.checkIn;
|
|
618
|
+
const checkOut = state.checkOut;
|
|
619
|
+
const nights = checkIn && checkOut
|
|
620
|
+
? Math.max(1, Math.round((checkOut - checkIn) / 86400000))
|
|
621
|
+
: 0;
|
|
622
|
+
const currency = (state.selectedRoom?.currency || 'USD').trim().toUpperCase();
|
|
623
|
+
const occupancies = Array.isArray(state.occupancies) ? state.occupancies : [{ adults: 2, children: 0, childrenAges: [] }];
|
|
624
|
+
const totalAdults = occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
625
|
+
const totalChildren = occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
626
|
+
const g = state.guest || {};
|
|
627
|
+
const rateIdentifier = state.selectedRate?.rate_identifier ?? state.selectedRate?.rate_code ?? state.selectedRate?.id ?? '';
|
|
628
|
+
// Same occupancies shape as availability body: adults, children, child_ages?, occupancy_index?
|
|
629
|
+
const occupanciesForPayload = occupancies.map((o, i) => ({
|
|
630
|
+
adults: o.adults ?? 0,
|
|
631
|
+
children: (o.children && o.children > 0) ? o.children : [],
|
|
632
|
+
occupancy_index: i + 1,
|
|
633
|
+
...(Array.isArray(o.childrenAges) && o.childrenAges.length ? { child_ages: o.childrenAges } : {}),
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
rate_identifier: rateIdentifier,
|
|
638
|
+
key: propertyKey,
|
|
639
|
+
metadata: {
|
|
640
|
+
hotel_name: state.selectedRoom?.name ?? 'Hotel',
|
|
641
|
+
room_type: state.selectedRoom?.name ?? 'Room',
|
|
642
|
+
check_in: toISO(checkIn),
|
|
643
|
+
check_out: toISO(checkOut),
|
|
644
|
+
guests: String(totalAdults + totalChildren),
|
|
645
|
+
nights: String(nights),
|
|
646
|
+
currency,
|
|
647
|
+
first_name: g.firstName ?? '',
|
|
648
|
+
last_name: g.lastName ?? '',
|
|
649
|
+
email: g.email ?? '',
|
|
650
|
+
occupancies: occupanciesForPayload,
|
|
651
|
+
source_transaction: 'booking engine',
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Decrypts property key to get property_id. Call this before fetchBookingEnginePref.
|
|
658
|
+
*
|
|
659
|
+
* @param {Object} options
|
|
660
|
+
* @param {string} options.baseUrl - Base URL for the API (e.g. 'https://thehotelplanet.com')
|
|
661
|
+
* @param {string} options.propertyKey - Hash from env (e.g. VITE_PROPERTY_KEY)
|
|
662
|
+
* @param {Object} [options.headers] - Optional headers (e.g. { 'X-API-Key': '...' })
|
|
663
|
+
* @param {string} [options.locale='en'] - Locale segment in path (default 'en')
|
|
664
|
+
* @returns {Promise<number|null>} property_id or null
|
|
665
|
+
*/
|
|
666
|
+
async function decryptPropertyId(options = {}) {
|
|
667
|
+
const { baseUrl, propertyKey, headers = {}, locale = 'en' } = options;
|
|
668
|
+
if (!propertyKey) return null;
|
|
669
|
+
const base = (baseUrl || '').replace(/\/$/, '');
|
|
670
|
+
const url = `${base}/${locale}/booking_engine/decrypt_property_id`;
|
|
671
|
+
const res = await fetch(url, {
|
|
672
|
+
method: 'POST',
|
|
673
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
674
|
+
body: JSON.stringify({ hash: propertyKey }),
|
|
675
|
+
});
|
|
676
|
+
if (!res.ok) throw new Error(res.statusText || 'Failed to decrypt property id');
|
|
677
|
+
const data = await res.json();
|
|
678
|
+
return data.property_id != null ? Number(data.property_id) : null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Fetches booking engine preferences (colors, links) from the config API.
|
|
683
|
+
*
|
|
684
|
+
* @param {Object} options
|
|
685
|
+
* @param {string} options.baseUrl - Base URL for the API (e.g. 'https://thehotelplanet.com')
|
|
686
|
+
* @param {string|number} options.propertyId - Property ID (from decryptPropertyId)
|
|
687
|
+
* @param {Object} [options.headers] - Optional headers (e.g. { 'X-API-Key': '...' })
|
|
688
|
+
* @param {string} [options.locale='en'] - Locale segment in path (default 'en')
|
|
689
|
+
* @returns {Promise<{ color_header, color_button, color_Text, link_privacy, link_terms }>}
|
|
690
|
+
*/
|
|
691
|
+
async function fetchBookingEnginePref(options = {}) {
|
|
692
|
+
const { baseUrl, propertyId, headers = {}, locale = 'en', debug = false } = options;
|
|
693
|
+
if (!propertyId) {
|
|
694
|
+
return Promise.resolve(null);
|
|
695
|
+
}
|
|
696
|
+
const base = (baseUrl || '').replace(/\/$/, '');
|
|
697
|
+
const url = `${base}/api/core/${locale}/booking_engine/booking_engine_pref?property_id=${encodeURIComponent(propertyId)}`;
|
|
698
|
+
const res = await fetch(url, {
|
|
699
|
+
method: 'GET',
|
|
700
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
701
|
+
});
|
|
702
|
+
if (!res.ok) throw new Error(res.statusText || 'Failed to fetch config');
|
|
703
|
+
|
|
704
|
+
const text = await res.text();
|
|
705
|
+
if (debug) console.log('[fetchBookingEnginePref] raw response:', { status: res.status, contentType: res.headers.get('content-type'), bodyLength: text?.length, bodyPreview: text?.slice(0, 200) });
|
|
706
|
+
if (!text || !text.trim()) return null;
|
|
707
|
+
|
|
708
|
+
let data;
|
|
709
|
+
try {
|
|
710
|
+
data = JSON.parse(text);
|
|
711
|
+
} catch {
|
|
712
|
+
if (debug) console.warn('[fetchBookingEnginePref] invalid JSON');
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Unwrap nested response: { data: {...} } or { result: {...} }
|
|
717
|
+
const pref = data?.data ?? data?.result ?? data;
|
|
718
|
+
const out = pref && typeof pref === 'object' ? pref : null;
|
|
719
|
+
if (debug) console.log('[fetchBookingEnginePref] parsed pref:', out);
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Maps booking_engine_pref API response to widget customColors.
|
|
725
|
+
*
|
|
726
|
+
* @param {Object} pref - Response from fetchBookingEnginePref
|
|
727
|
+
* @returns {Object} customColors for the widget { background, text, primary, primaryText } plus link_privacy, link_terms
|
|
728
|
+
*/
|
|
729
|
+
function mapPrefToCustomColors(pref) {
|
|
730
|
+
if (!pref) return null;
|
|
731
|
+
return {
|
|
732
|
+
background: pref.color_header || '#1a1a1a',
|
|
733
|
+
text: pref.color_Text || '#e0e0e0',
|
|
734
|
+
primary: pref.color_button || '#3b82f6',
|
|
735
|
+
primaryText: '#ffffff',
|
|
736
|
+
link_privacy: pref.link_privacy || '#',
|
|
737
|
+
link_terms: pref.link_terms || '#',
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Support both ESM and CJS
|
|
742
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
743
|
+
module.exports = { createBookingApi, DEFAULT_ROOMS, DEFAULT_RATES, buildCheckoutPayload, buildPaymentIntentPayload, decryptPropertyId, fetchBookingEnginePref, mapPrefToCustomColors, getCurrencySymbol, formatPrice };
|
|
744
|
+
}
|
|
745
|
+
if (typeof window !== 'undefined') {
|
|
746
|
+
window.createBookingApi = createBookingApi;
|
|
747
|
+
window.buildCheckoutPayload = buildCheckoutPayload;
|
|
748
|
+
window.buildPaymentIntentPayload = buildPaymentIntentPayload;
|
|
749
|
+
window.decryptPropertyId = decryptPropertyId;
|
|
750
|
+
window.fetchBookingEnginePref = fetchBookingEnginePref;
|
|
751
|
+
window.mapPrefToCustomColors = mapPrefToCustomColors;
|
|
752
|
+
window.getCurrencySymbol = getCurrencySymbol;
|
|
753
|
+
window.formatPrice = formatPrice;
|
|
754
|
+
}
|
|
755
|
+
// Standalone bundle - includes widget class and CSS injection
|
|
756
|
+
(function() {
|
|
757
|
+
'use strict';
|
|
758
|
+
|
|
759
|
+
// Inject CSS
|
|
760
|
+
if (!document.getElementById('booking-widget-styles')) {
|
|
761
|
+
const style = document.createElement('style');
|
|
762
|
+
style.id = 'booking-widget-styles';
|
|
763
|
+
style.textContent = `/* CSS will be injected here - see dist/booking-widget.css */`;
|
|
764
|
+
document.head.appendChild(style);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Icons (Lucide/shadcn)
|
|
768
|
+
const icons = {
|
|
769
|
+
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>`,
|
|
770
|
+
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>`,
|
|
771
|
+
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>`,
|
|
772
|
+
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>`,
|
|
773
|
+
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>`,
|
|
774
|
+
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>`,
|
|
775
|
+
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>`,
|
|
776
|
+
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>`,
|
|
777
|
+
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>`,
|
|
778
|
+
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>`,
|
|
779
|
+
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>`,
|
|
780
|
+
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>`,
|
|
781
|
+
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>`,
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
function iconHTML(name, size = '1em') {
|
|
785
|
+
const icon = icons[name];
|
|
786
|
+
if (!icon) {
|
|
787
|
+
console.warn(`Icon "${name}" not found`);
|
|
788
|
+
return '';
|
|
789
|
+
}
|
|
790
|
+
// Extract numeric value from size (e.g., '1.2em' -> 1.2, '16' -> 16, '1.5em' -> 1.5)
|
|
791
|
+
const sizeMatch = size.match(/([\d.]+)(\w*)/);
|
|
792
|
+
const sizeNum = sizeMatch ? parseFloat(sizeMatch[1]) : 16;
|
|
793
|
+
const sizeUnit = sizeMatch && sizeMatch[2] ? sizeMatch[2] : (size.includes('em') ? 'em' : 'em');
|
|
794
|
+
|
|
795
|
+
// Replace SVG width/height with dynamic size, preserving viewBox
|
|
796
|
+
// CRITICAL: Ensure all SVG elements have fill="none" explicitly set
|
|
797
|
+
let sizedIcon = icon
|
|
798
|
+
.replace(/width="[^"]*"/g, `width="${sizeNum}${sizeUnit}"`)
|
|
799
|
+
.replace(/height="[^"]*"/g, `height="${sizeNum}${sizeUnit}"`);
|
|
800
|
+
|
|
801
|
+
// Ensure SVG root has fill="none" and visible stroke
|
|
802
|
+
sizedIcon = sizedIcon.replace(/<svg([^>]*)>/g, (match, attrs) => {
|
|
803
|
+
let out = `<svg${attrs}`;
|
|
804
|
+
if (!out.includes('fill=')) out += ' fill="none"';
|
|
805
|
+
out += ' style="display:block;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round">';
|
|
806
|
+
return out;
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Ensure ALL child elements have fill="none"
|
|
810
|
+
sizedIcon = sizedIcon.replace(/<(rect|circle|path|polygon|polyline|ellipse|line)(\s+[^>]*?)(?:\s*\/)?>/gi, (match, tag, attrs) => {
|
|
811
|
+
if (!attrs || !attrs.includes('fill=')) {
|
|
812
|
+
return `<${tag}${attrs} fill="none">`;
|
|
813
|
+
}
|
|
814
|
+
return match.replace(/fill="[^"]*"/gi, 'fill="none"');
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
return `<span class="icon" style="display:inline-flex;align-items:center;justify-content:center;width:${size};height:${size};min-width:${size};min-height:${size};vertical-align:middle;flex-shrink:0;color:inherit;">${sizedIcon}</span>`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Copy the BookingWidget class from core/widget.js
|
|
821
|
+
// This is a simplified version that will be bundled
|
|
822
|
+
const apiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
|
|
823
|
+
const builtInStripeKey = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_STRIPE_KEY__) ? window.__BOOKING_WIDGET_STRIPE_KEY__ : '';
|
|
824
|
+
const builtInCreatePaymentIntent = (apiBase && typeof window !== 'undefined')
|
|
825
|
+
? function (payload) {
|
|
826
|
+
return fetch(apiBase.replace(/\/$/, '') + '/proxy/create-payment-intent', {
|
|
827
|
+
method: 'POST',
|
|
828
|
+
headers: { 'Content-Type': 'application/json' },
|
|
829
|
+
body: JSON.stringify(payload),
|
|
830
|
+
}).then(function (r) {
|
|
831
|
+
if (!r.ok) throw new Error(r.statusText || 'Payment intent failed');
|
|
832
|
+
return r.json();
|
|
833
|
+
}).then(function (data) {
|
|
834
|
+
return {
|
|
835
|
+
clientSecret: data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret,
|
|
836
|
+
confirmationToken: data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token,
|
|
837
|
+
};
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
: null;
|
|
841
|
+
|
|
842
|
+
class BookingWidget {
|
|
843
|
+
constructor(options = {}) {
|
|
844
|
+
this.options = {
|
|
845
|
+
containerId: options.containerId || 'booking-widget-container',
|
|
846
|
+
onOpen: options.onOpen || null,
|
|
847
|
+
onClose: options.onClose || null,
|
|
848
|
+
onComplete: options.onComplete || null,
|
|
849
|
+
onBeforeConfirm: options.onBeforeConfirm || null,
|
|
850
|
+
createPaymentIntent: options.createPaymentIntent || builtInCreatePaymentIntent,
|
|
851
|
+
onBookingComplete: options.onBookingComplete || null,
|
|
852
|
+
confirmationBaseUrl: options.confirmationBaseUrl || (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) || 'https://ai.thehotelplanet.com',
|
|
853
|
+
stripePublishableKey: options.stripePublishableKey || builtInStripeKey || null,
|
|
854
|
+
propertyId: options.propertyId != null && options.propertyId !== '' ? options.propertyId : null,
|
|
855
|
+
propertyKey: options.propertyKey || null,
|
|
856
|
+
bookingApi: options.bookingApi || null,
|
|
857
|
+
cssUrl: options.cssUrl || null,
|
|
858
|
+
// Color customization options
|
|
859
|
+
colors: {
|
|
860
|
+
background: options.colors?.background || null,
|
|
861
|
+
text: options.colors?.text || null,
|
|
862
|
+
primary: options.colors?.primary || null,
|
|
863
|
+
primaryText: options.colors?.primaryText || null,
|
|
864
|
+
...options.colors
|
|
865
|
+
},
|
|
866
|
+
...options
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
this.state = {
|
|
870
|
+
step: 'dates',
|
|
871
|
+
checkIn: null,
|
|
872
|
+
checkOut: null,
|
|
873
|
+
rooms: 1,
|
|
874
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
875
|
+
selectedRoom: null,
|
|
876
|
+
selectedRate: null,
|
|
877
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' },
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
this.confirmationCode = null;
|
|
881
|
+
this.confirmationToken = null;
|
|
882
|
+
this.confirmationDetails = null;
|
|
883
|
+
this.confirmationStatus = null;
|
|
884
|
+
this.confirmationPolling = false;
|
|
885
|
+
this._confirmationPollTimer = null;
|
|
886
|
+
this._confirmationPollCancelled = false;
|
|
887
|
+
this.apiError = null;
|
|
888
|
+
this.loadingRooms = false;
|
|
889
|
+
this.checkoutShowPaymentForm = false;
|
|
890
|
+
this.paymentElementReady = false;
|
|
891
|
+
this.stripeInstance = null;
|
|
892
|
+
this.elementsInstance = null;
|
|
893
|
+
|
|
894
|
+
const baseSteps = [
|
|
895
|
+
{ key: 'dates', label: 'Dates & Guests', num: '01' },
|
|
896
|
+
{ key: 'rooms', label: 'Room', num: '02' },
|
|
897
|
+
{ key: 'rates', label: 'Rate', num: '03' },
|
|
898
|
+
{ key: 'summary', label: 'Summary', num: '04' },
|
|
899
|
+
];
|
|
900
|
+
this.hasStripe = !!(this.options.stripePublishableKey && typeof this.options.createPaymentIntent === 'function');
|
|
901
|
+
this.STEPS = this.hasStripe ? [...baseSteps, { key: 'payment', label: 'Payment', num: '05' }] : baseSteps;
|
|
902
|
+
|
|
903
|
+
this.ROOMS = [];
|
|
904
|
+
this.RATES = [];
|
|
905
|
+
const defaultApiBase = (typeof window !== 'undefined' && window.__BOOKING_WIDGET_API_BASE_URL__) ? window.__BOOKING_WIDGET_API_BASE_URL__ : '';
|
|
906
|
+
this.bookingApi = this.options.bookingApi || ((this.options.propertyKey || defaultApiBase) && typeof window.createBookingApi === 'function'
|
|
907
|
+
? window.createBookingApi({
|
|
908
|
+
availabilityBaseUrl: defaultApiBase || '',
|
|
909
|
+
propertyKey: this.options.propertyKey || undefined,
|
|
910
|
+
})
|
|
911
|
+
: null);
|
|
912
|
+
|
|
913
|
+
this.calendarMonth = null;
|
|
914
|
+
this.calendarYear = null;
|
|
915
|
+
this.pickState = 0;
|
|
916
|
+
this.container = null;
|
|
917
|
+
this.overlay = null;
|
|
918
|
+
this.widget = null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
getNights() {
|
|
922
|
+
if (!this.state.checkIn || !this.state.checkOut) return 0;
|
|
923
|
+
return Math.max(1, Math.round((this.state.checkOut - this.state.checkIn) / 86400000));
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
getTotalPrice() {
|
|
927
|
+
if (!this.state.selectedRoom || !this.state.selectedRate) return 0;
|
|
928
|
+
const nights = this.getNights();
|
|
929
|
+
const rooms = this.state.rooms;
|
|
930
|
+
const roomTotal = Math.round(this.state.selectedRoom.basePrice * this.state.selectedRate.priceModifier * nights * rooms);
|
|
931
|
+
const fees = this.state.selectedRate.fees ?? [];
|
|
932
|
+
let add = 0;
|
|
933
|
+
fees.forEach(f => { if (!f.included) add += f.perNight ? f.amount * nights * rooms : f.amount; });
|
|
934
|
+
const vat = this.state.selectedRate.vat;
|
|
935
|
+
if (vat && !vat.included) add += vat.value || 0;
|
|
936
|
+
return Math.round(roomTotal + add);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
fmt(date) {
|
|
940
|
+
if (!date) return '';
|
|
941
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
fmtLong(date) {
|
|
945
|
+
if (!date) return '';
|
|
946
|
+
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
stepIndex(key) {
|
|
950
|
+
return this.STEPS.findIndex(s => s.key === key);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
open() {
|
|
954
|
+
if (!this.container) this.init();
|
|
955
|
+
this.overlay.classList.add('active');
|
|
956
|
+
this.widget.classList.add('active');
|
|
957
|
+
this.render();
|
|
958
|
+
if (this.options.onOpen) this.options.onOpen();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
close() {
|
|
962
|
+
this._confirmationPollCancelled = true;
|
|
963
|
+
this.confirmationPolling = false;
|
|
964
|
+
if (this._confirmationPollTimer) clearTimeout(this._confirmationPollTimer);
|
|
965
|
+
this._confirmationPollTimer = null;
|
|
966
|
+
if (this.overlay) this.overlay.classList.remove('active');
|
|
967
|
+
if (this.widget) this.widget.classList.remove('active');
|
|
968
|
+
// Reset state so next open starts from step 1 with no selection
|
|
969
|
+
Object.assign(this.state, {
|
|
970
|
+
step: 'dates',
|
|
971
|
+
checkIn: null,
|
|
972
|
+
checkOut: null,
|
|
973
|
+
rooms: 1,
|
|
974
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
975
|
+
selectedRoom: null,
|
|
976
|
+
selectedRate: null,
|
|
977
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' }
|
|
978
|
+
});
|
|
979
|
+
if (this.options.onClose) this.options.onClose();
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
goToStep(step) {
|
|
983
|
+
if (step !== 'summary' && step !== 'payment') {
|
|
984
|
+
this.checkoutShowPaymentForm = false;
|
|
985
|
+
this.paymentElementReady = false;
|
|
986
|
+
this.stripeInstance = null;
|
|
987
|
+
if (this.elementsInstance) {
|
|
988
|
+
const el = document.getElementById('booking-widget-payment-element');
|
|
989
|
+
if (el) el.innerHTML = '';
|
|
990
|
+
}
|
|
991
|
+
this.elementsInstance = null;
|
|
992
|
+
}
|
|
993
|
+
if (step === 'payment') this.checkoutShowPaymentForm = true;
|
|
994
|
+
this.state.step = step;
|
|
995
|
+
this.apiError = null;
|
|
996
|
+
if (step === 'rooms' && this.bookingApi) {
|
|
997
|
+
this.loadingRooms = true;
|
|
998
|
+
this.render();
|
|
999
|
+
const occupancies = (this.state.occupancies || []).slice(0, this.state.rooms).map((occ, i) => ({
|
|
1000
|
+
adults: occ.adults ?? 1,
|
|
1001
|
+
children: (occ.childrenAges || []).slice(0, occ.children || 0).map(a => Number(a) || 0),
|
|
1002
|
+
occupancy_index: i + 1,
|
|
1003
|
+
}));
|
|
1004
|
+
const params = { checkIn: this.state.checkIn, checkOut: this.state.checkOut, rooms: this.state.rooms, occupancies };
|
|
1005
|
+
this.bookingApi.fetchRooms(params).then((rooms) => {
|
|
1006
|
+
this.ROOMS = Array.isArray(rooms) ? rooms : [];
|
|
1007
|
+
this.loadingRooms = false;
|
|
1008
|
+
this.render();
|
|
1009
|
+
}).catch((err) => {
|
|
1010
|
+
this.apiError = err.message || 'Failed to load rooms';
|
|
1011
|
+
this.loadingRooms = false;
|
|
1012
|
+
this.render();
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (step === 'rates' && this.state.selectedRoom) {
|
|
1017
|
+
this.RATES = Array.isArray(this.state.selectedRoom.rates) && this.state.selectedRoom.rates.length
|
|
1018
|
+
? this.state.selectedRoom.rates
|
|
1019
|
+
: [];
|
|
1020
|
+
this.render();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
this.render();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
init() {
|
|
1027
|
+
const container = document.getElementById(this.options.containerId);
|
|
1028
|
+
if (!container) {
|
|
1029
|
+
console.error(`Container with id "${this.options.containerId}" not found`);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
this.container = container;
|
|
1034
|
+
|
|
1035
|
+
this.overlay = document.createElement('div');
|
|
1036
|
+
this.overlay.className = 'booking-widget-overlay';
|
|
1037
|
+
this.overlay.addEventListener('click', () => this.close());
|
|
1038
|
+
container.appendChild(this.overlay);
|
|
1039
|
+
|
|
1040
|
+
this.widget = document.createElement('div');
|
|
1041
|
+
this.widget.className = 'booking-widget-modal';
|
|
1042
|
+
this.widget.innerHTML = `
|
|
1043
|
+
<button class="booking-widget-close">${iconHTML('x', '1.5em')}</button>
|
|
1044
|
+
<div class="booking-widget-step-indicator"></div>
|
|
1045
|
+
<div class="booking-widget-step-content"></div>
|
|
1046
|
+
`;
|
|
1047
|
+
this.widget.querySelector('.booking-widget-close').addEventListener('click', () => this.close());
|
|
1048
|
+
container.appendChild(this.widget);
|
|
1049
|
+
|
|
1050
|
+
if (typeof window !== 'undefined') {
|
|
1051
|
+
window.bookingWidgetInstance = this;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Apply custom colors
|
|
1055
|
+
this.applyColors();
|
|
1056
|
+
|
|
1057
|
+
this.injectCSS();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
applyColors() {
|
|
1061
|
+
if (!this.widget) return;
|
|
1062
|
+
|
|
1063
|
+
const colors = this.options.colors;
|
|
1064
|
+
if (!colors) return;
|
|
1065
|
+
|
|
1066
|
+
const style = this.widget.style;
|
|
1067
|
+
|
|
1068
|
+
if (colors.background) {
|
|
1069
|
+
style.setProperty('--bg', colors.background);
|
|
1070
|
+
style.setProperty('--card', colors.background);
|
|
1071
|
+
}
|
|
1072
|
+
if (colors.text) {
|
|
1073
|
+
style.setProperty('--fg', colors.text);
|
|
1074
|
+
style.setProperty('--card-fg', colors.text);
|
|
1075
|
+
}
|
|
1076
|
+
if (colors.primary) {
|
|
1077
|
+
style.setProperty('--primary', colors.primary);
|
|
1078
|
+
}
|
|
1079
|
+
if (colors.primaryText) {
|
|
1080
|
+
style.setProperty('--primary-fg', colors.primaryText);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
injectCSS() {
|
|
1085
|
+
if (document.getElementById('booking-widget-styles')) return;
|
|
1086
|
+
|
|
1087
|
+
if (this.options.cssUrl) {
|
|
1088
|
+
const link = document.createElement('link');
|
|
1089
|
+
link.id = 'booking-widget-styles';
|
|
1090
|
+
link.rel = 'stylesheet';
|
|
1091
|
+
link.href = this.options.cssUrl;
|
|
1092
|
+
document.head.appendChild(link);
|
|
1093
|
+
} else {
|
|
1094
|
+
// Inline CSS would go here in a real bundle
|
|
1095
|
+
console.warn('CSS not loaded. Please include booking-widget.css or provide cssUrl option.');
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
render() {
|
|
1100
|
+
this.renderStepIndicator();
|
|
1101
|
+
this.renderStepContent();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
renderStepIndicator() {
|
|
1105
|
+
const el = this.widget.querySelector('.booking-widget-step-indicator');
|
|
1106
|
+
if (this.state.step === 'confirmation') {
|
|
1107
|
+
el.innerHTML = '';
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const ci = this.stepIndex(this.state.step);
|
|
1111
|
+
let html = '';
|
|
1112
|
+
this.STEPS.forEach((s, i) => {
|
|
1113
|
+
const cls = i === ci ? 'active' : i < ci ? 'past' : 'future';
|
|
1114
|
+
const widget = this;
|
|
1115
|
+
html += `<div class="step-item">
|
|
1116
|
+
<span class="step-circle ${cls}" ${cls === 'past' ? `onclick="window.bookingWidgetInstance.goToStep("${String(s.key).replace(/"/g, '"')}")"` : ''}>${i < ci ? iconHTML('check', '0.75em') : s.num}</span>
|
|
1117
|
+
<span class="step-label ${cls}" ${cls === 'past' ? `onclick="window.bookingWidgetInstance.goToStep("${String(s.key).replace(/"/g, '"')}")"` : ''}>${s.label}</span>`;
|
|
1118
|
+
if (i < this.STEPS.length - 1) {
|
|
1119
|
+
html += `<span class="step-line"><span class="step-line-fill ${i < ci ? 'filled' : ''}"></span></span>`;
|
|
1120
|
+
}
|
|
1121
|
+
html += '</div>';
|
|
1122
|
+
});
|
|
1123
|
+
el.innerHTML = html;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
renderStepContent() {
|
|
1127
|
+
const el = this.widget.querySelector('.booking-widget-step-content');
|
|
1128
|
+
switch (this.state.step) {
|
|
1129
|
+
case 'dates':
|
|
1130
|
+
el.innerHTML = this.renderDatesStep();
|
|
1131
|
+
this.initCalendar();
|
|
1132
|
+
if (this.pickState === 1) {
|
|
1133
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
1134
|
+
if (popup) popup.classList.add('open');
|
|
1135
|
+
this.renderCalendar();
|
|
1136
|
+
}
|
|
1137
|
+
break;
|
|
1138
|
+
case 'rooms':
|
|
1139
|
+
el.innerHTML = this.renderRoomsStep();
|
|
1140
|
+
break;
|
|
1141
|
+
case 'rates':
|
|
1142
|
+
el.innerHTML = this.renderRatesStep();
|
|
1143
|
+
break;
|
|
1144
|
+
case 'summary':
|
|
1145
|
+
el.innerHTML = this.renderSummaryStep();
|
|
1146
|
+
break;
|
|
1147
|
+
case 'payment':
|
|
1148
|
+
el.innerHTML = this.renderPaymentStep();
|
|
1149
|
+
break;
|
|
1150
|
+
case 'confirmation':
|
|
1151
|
+
el.innerHTML = this.renderConfirmationStep();
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
renderDatesStep() {
|
|
1157
|
+
return `
|
|
1158
|
+
<h2 class="step-title">Plan Your Stay</h2>
|
|
1159
|
+
<p class="step-subtitle">Select your dates and guests to begin</p>
|
|
1160
|
+
<div style="max-width:32em;margin:0 auto;">
|
|
1161
|
+
<label class="form-label">Dates</label>
|
|
1162
|
+
<div class="date-wrapper">
|
|
1163
|
+
<div class="date-trigger" onclick="window.bookingWidgetInstance.toggleCalendar()">
|
|
1164
|
+
${iconHTML('calendar', '1.2em')}
|
|
1165
|
+
<span ${this.state.checkIn ? '' : 'class="placeholder"'}>${this.state.checkIn ? this.fmt(this.state.checkIn) : 'Check-in'}</span>
|
|
1166
|
+
→
|
|
1167
|
+
<span ${this.state.checkOut ? '' : 'class="placeholder"'}>${this.state.checkOut ? this.fmt(this.state.checkOut) : 'Check-out'}</span>
|
|
1168
|
+
</div>
|
|
1169
|
+
<div class="calendar-popup"></div>
|
|
1170
|
+
</div>
|
|
1171
|
+
<label class="form-label" style="margin-top:1.5em;">${iconHTML('users', '1.2em')} Guests & Rooms</label>
|
|
1172
|
+
<div class="guests-rooms-section">
|
|
1173
|
+
${(this.state.occupancies || []).slice(0, this.state.rooms).map((occ, roomIdx) => {
|
|
1174
|
+
const adults = occ.adults ?? 1;
|
|
1175
|
+
const children = occ.children ?? 0;
|
|
1176
|
+
const ages = occ.childrenAges || [];
|
|
1177
|
+
const childRows = children > 0 ? Array.from({ length: children }, (_, i) => {
|
|
1178
|
+
const age = ages[i] ?? 0;
|
|
1179
|
+
return `<div class="child-age-row" style="flex:0 1 calc(50% - 0.5em);min-width:0;margin-top:0.5em;">
|
|
1180
|
+
<label class="form-label" style="font-size:0.75em;color:var(--muted);">Child ${i + 1} age</label>
|
|
1181
|
+
<select class="form-input" style="width:100%;margin-top:0.25em;" data-room="${roomIdx}" data-child="${i}" onchange="window.bookingWidgetInstance.updateChildAge(${roomIdx}, ${i}, parseInt(this.value,10))">
|
|
1182
|
+
${Array.from({ length: 18 }, (_, a) => `<option value="${a}" ${age === a ? 'selected' : ''}>${a} year${a !== 1 ? 's' : ''}</option>`).join('')}
|
|
1183
|
+
</select>
|
|
1184
|
+
</div>`;
|
|
1185
|
+
}).join('') : '';
|
|
1186
|
+
const removeBtn = this.state.rooms > 1 ? `<button type="button" class="remove-room-btn" onclick="window.bookingWidgetInstance.removeRoom(${roomIdx})" aria-label="Remove room">Remove</button>` : '';
|
|
1187
|
+
return `<div class="room-card">
|
|
1188
|
+
<div class="room-card-header"><span class="room-card-title">Room ${roomIdx + 1}</span>${removeBtn}</div>
|
|
1189
|
+
${this.counterHTMLForRoom('Adults', adults, 1, 8, 'adults', roomIdx)}
|
|
1190
|
+
${this.counterHTMLForRoom('Children', children, 0, 6, 'children', roomIdx)}
|
|
1191
|
+
${children > 0 ? `<div class="child-ages-section" style="display:flex;flex-wrap:wrap;gap:0.5em 1em;">${childRows}</div>` : ''}
|
|
1192
|
+
</div>`;
|
|
1193
|
+
}).join('')}
|
|
1194
|
+
<button type="button" class="add-room-btn" ${this.state.rooms >= 5 ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter('rooms',1)">+ Add room</button>
|
|
1195
|
+
</div>
|
|
1196
|
+
<button class="btn-primary" ${this.state.checkIn && this.state.checkOut ? '' : 'disabled'} onclick="window.bookingWidgetInstance.goToStep("rooms")">Select Room</button>
|
|
1197
|
+
</div>`;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
counterHTML(label, val, min, max, field) {
|
|
1201
|
+
return `<div class="counter-row">
|
|
1202
|
+
<span class="counter-label">${label}</span>
|
|
1203
|
+
<div class="counter-controls">
|
|
1204
|
+
<button class="counter-btn" ${val <= min ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter("${field}",-1)">−</button>
|
|
1205
|
+
<span class="counter-val">${val}</span>
|
|
1206
|
+
<button class="counter-btn" ${val >= max ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter("${field}",1)">+</button>
|
|
1207
|
+
</div>
|
|
1208
|
+
</div>`;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
counterHTMLForRoom(label, val, min, max, field, roomIndex) {
|
|
1212
|
+
return `<div class="counter-row">
|
|
1213
|
+
<span class="counter-label">${label}</span>
|
|
1214
|
+
<div class="counter-controls">
|
|
1215
|
+
<button class="counter-btn" ${val <= min ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter("${field}",-1,${roomIndex})">−</button>
|
|
1216
|
+
<span class="counter-val">${val}</span>
|
|
1217
|
+
<button class="counter-btn" ${val >= max ? 'disabled' : ''} onclick="window.bookingWidgetInstance.changeCounter("${field}",1,${roomIndex})">+</button>
|
|
1218
|
+
</div>
|
|
1219
|
+
</div>`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
getTotalGuests() {
|
|
1223
|
+
const occ = this.state.occupancies || [];
|
|
1224
|
+
return {
|
|
1225
|
+
adults: occ.reduce((s, o) => s + (o.adults || 0), 0),
|
|
1226
|
+
children: occ.reduce((s, o) => s + (o.children || 0), 0),
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
changeCounter(field, delta, roomIndex = 0) {
|
|
1231
|
+
const limits = { adults: [1,8], children: [0,6], rooms: [1,5] };
|
|
1232
|
+
const occ = [...(this.state.occupancies || [{ adults: 2, children: 0, childrenAges: [] }])];
|
|
1233
|
+
if (field === 'rooms') {
|
|
1234
|
+
const next = Math.max(limits.rooms[0], Math.min(limits.rooms[1], this.state.rooms + delta));
|
|
1235
|
+
while (occ.length < next) occ.push({ adults: 1, children: 0, childrenAges: [] });
|
|
1236
|
+
occ.length = next;
|
|
1237
|
+
this.state.rooms = next;
|
|
1238
|
+
this.state.occupancies = occ;
|
|
1239
|
+
this.render();
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
1243
|
+
const next = Math.max(limits[field][0], Math.min(limits[field][1], (occ[roomIndex][field] ?? (field === 'adults' ? 1 : 0)) + delta));
|
|
1244
|
+
occ[roomIndex] = { ...occ[roomIndex], [field]: next };
|
|
1245
|
+
if (field === 'children') {
|
|
1246
|
+
const ages = (occ[roomIndex].childrenAges || []).slice(0, next);
|
|
1247
|
+
while (ages.length < next) ages.push(0);
|
|
1248
|
+
occ[roomIndex].childrenAges = ages;
|
|
1249
|
+
}
|
|
1250
|
+
this.state.occupancies = occ;
|
|
1251
|
+
this.render();
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
updateChildAge(roomIndex, childIndex, value) {
|
|
1255
|
+
const age = Math.max(0, Math.min(17, Number(value) || 0));
|
|
1256
|
+
const occ = [...(this.state.occupancies || [])];
|
|
1257
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
1258
|
+
const ages = [...(occ[roomIndex].childrenAges || [])];
|
|
1259
|
+
if (childIndex >= 0 && childIndex < ages.length) ages[childIndex] = age;
|
|
1260
|
+
occ[roomIndex] = { ...occ[roomIndex], childrenAges: ages };
|
|
1261
|
+
this.state.occupancies = occ;
|
|
1262
|
+
this.render();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
removeRoom(roomIndex) {
|
|
1266
|
+
if (this.state.rooms <= 1) return;
|
|
1267
|
+
const occ = [...(this.state.occupancies || [])];
|
|
1268
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
1269
|
+
occ.splice(roomIndex, 1);
|
|
1270
|
+
this.state.occupancies = occ;
|
|
1271
|
+
this.state.rooms = occ.length;
|
|
1272
|
+
this.render();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
initCalendar() {
|
|
1276
|
+
const now = new Date();
|
|
1277
|
+
this.calendarMonth = now.getMonth();
|
|
1278
|
+
this.calendarYear = now.getFullYear();
|
|
1279
|
+
// 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)
|
|
1280
|
+
this.pickState = (this.state.checkIn && !this.state.checkOut) ? 1 : 0;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
toggleCalendar() {
|
|
1284
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
1285
|
+
popup.classList.toggle('open');
|
|
1286
|
+
if (popup.classList.contains('open')) {
|
|
1287
|
+
this.initCalendar();
|
|
1288
|
+
this.renderCalendar();
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
renderCalendar() {
|
|
1293
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
1294
|
+
const m1 = this.calendarMonth, y1 = this.calendarYear;
|
|
1295
|
+
const m2 = m1 === 11 ? 0 : m1 + 1, y2 = m1 === 11 ? y1 + 1 : y1;
|
|
1296
|
+
popup.innerHTML = `
|
|
1297
|
+
<div class="cal-header">
|
|
1298
|
+
<button onclick="window.bookingWidgetInstance.calNav(-1)">‹</button>
|
|
1299
|
+
<span class="cal-title">${this.monthName(m1)} ${y1}</span>
|
|
1300
|
+
<span class="cal-title">${this.monthName(m2)} ${y2}</span>
|
|
1301
|
+
<button onclick="window.bookingWidgetInstance.calNav(1)">›</button>
|
|
1302
|
+
</div>
|
|
1303
|
+
<div class="cal-months">${this.buildMonth(y1, m1)}${this.buildMonth(y2, m2)}</div>`;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
monthName(m) {
|
|
1307
|
+
return ['January','February','March','April','May','June','July','August','September','October','November','December'][m];
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
calNav(dir) {
|
|
1311
|
+
this.calendarMonth += dir;
|
|
1312
|
+
if (this.calendarMonth > 11) { this.calendarMonth = 0; this.calendarYear++; }
|
|
1313
|
+
if (this.calendarMonth < 0) { this.calendarMonth = 11; this.calendarYear--; }
|
|
1314
|
+
this.renderCalendar();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
buildMonth(year, month) {
|
|
1318
|
+
const today = new Date(); today.setHours(0,0,0,0);
|
|
1319
|
+
const first = new Date(year, month, 1);
|
|
1320
|
+
const days = new Date(year, month + 1, 0).getDate();
|
|
1321
|
+
const startDay = first.getDay();
|
|
1322
|
+
const names = ['Su','Mo','Tu','We','Th','Fr','Sa'];
|
|
1323
|
+
let html = '<div class="cal-grid">';
|
|
1324
|
+
names.forEach(n => { html += `<div class="cal-day-name">${n}</div>`; });
|
|
1325
|
+
for (let i = 0; i < startDay; i++) html += '<div class="cal-day empty"></div>';
|
|
1326
|
+
for (let d = 1; d <= days; d++) {
|
|
1327
|
+
const date = new Date(year, month, d);
|
|
1328
|
+
const disabled = date < today;
|
|
1329
|
+
let cls = 'cal-day';
|
|
1330
|
+
if (disabled) cls += ' disabled';
|
|
1331
|
+
if (date.getTime() === today.getTime()) cls += ' today';
|
|
1332
|
+
if (this.state.checkIn && date.getTime() === this.state.checkIn.getTime()) cls += ' selected';
|
|
1333
|
+
if (this.state.checkOut && date.getTime() === this.state.checkOut.getTime()) cls += ' selected';
|
|
1334
|
+
if (this.state.checkIn && this.state.checkOut && date > this.state.checkIn && date < this.state.checkOut) cls += ' in-range';
|
|
1335
|
+
html += `<button class="${cls}" ${disabled ? 'disabled' : `onclick="window.bookingWidgetInstance.pickDate(${year},${month},${d})"`}>${d}</button>`;
|
|
1336
|
+
}
|
|
1337
|
+
html += '</div>';
|
|
1338
|
+
return html;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
pickDate(y, m, d) {
|
|
1342
|
+
const date = new Date(y, m, d);
|
|
1343
|
+
if (this.pickState === 0 || (this.state.checkIn && date <= this.state.checkIn)) {
|
|
1344
|
+
this.state.checkIn = date; this.state.checkOut = null; this.pickState = 1;
|
|
1345
|
+
this.render();
|
|
1346
|
+
this.initCalendar();
|
|
1347
|
+
this.pickState = 1;
|
|
1348
|
+
const popup = this.widget.querySelector('.calendar-popup');
|
|
1349
|
+
if (popup) popup.classList.add('open');
|
|
1350
|
+
this.renderCalendar();
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
this.state.checkOut = date; this.pickState = 0;
|
|
1354
|
+
this.widget.querySelector('.calendar-popup').classList.remove('open');
|
|
1355
|
+
this.render();
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
renderRoomsStep() {
|
|
1359
|
+
if (this.loadingRooms) {
|
|
1360
|
+
const skeletonCard = `
|
|
1361
|
+
<div class="room-skeleton">
|
|
1362
|
+
<div class="room-skeleton-img"></div>
|
|
1363
|
+
<div class="room-skeleton-body">
|
|
1364
|
+
<div class="room-skeleton-line title"></div>
|
|
1365
|
+
<div class="room-skeleton-line price"></div>
|
|
1366
|
+
<div class="room-skeleton-line desc"></div>
|
|
1367
|
+
<div class="room-skeleton-line desc"></div>
|
|
1368
|
+
<div class="room-skeleton-line desc"></div>
|
|
1369
|
+
<div class="room-skeleton-meta">
|
|
1370
|
+
<div class="room-skeleton-line meta"></div>
|
|
1371
|
+
<div class="room-skeleton-line meta"></div>
|
|
1372
|
+
</div>
|
|
1373
|
+
<div class="room-skeleton-tags">
|
|
1374
|
+
<div class="room-skeleton-tag"></div>
|
|
1375
|
+
<div class="room-skeleton-tag"></div>
|
|
1376
|
+
<div class="room-skeleton-tag"></div>
|
|
1377
|
+
</div>
|
|
1378
|
+
</div>
|
|
1379
|
+
</div>`;
|
|
1380
|
+
const skeletons = Array(4).fill(skeletonCard).join('');
|
|
1381
|
+
return `
|
|
1382
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
1383
|
+
<p class="step-subtitle">Each space is crafted for an unforgettable experience</p>
|
|
1384
|
+
<div class="room-grid-wrapper">
|
|
1385
|
+
<div class="room-grid">${skeletons}</div>
|
|
1386
|
+
</div>`;
|
|
1387
|
+
}
|
|
1388
|
+
if (this.apiError) {
|
|
1389
|
+
return `
|
|
1390
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
1391
|
+
<p class="step-subtitle" style="color:var(--destructive,#ef4444);">${this.apiError}</p>
|
|
1392
|
+
<button class="btn-secondary" onclick="window.bookingWidgetInstance.goToStep("rooms")">Try again</button>`;
|
|
1393
|
+
}
|
|
1394
|
+
if (!this.bookingApi || this.ROOMS.length === 0) {
|
|
1395
|
+
return `
|
|
1396
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
1397
|
+
<p class="step-subtitle">${this.bookingApi ? 'No rooms available for the selected dates.' : 'Set propertyKey to load rooms from the API.'}</p>
|
|
1398
|
+
${this.bookingApi ? '<button class="btn-secondary" onclick="window.bookingWidgetInstance.goToStep("dates")">Change dates</button>' : ''}`;
|
|
1399
|
+
}
|
|
1400
|
+
const formatRoomPrice = (r) => (typeof window.formatPrice === 'function' ? window.formatPrice(r.basePrice || 0, r.currency) : (r.currency || '$') + ' ' + (r.basePrice || 0).toLocaleString());
|
|
1401
|
+
let cards = this.ROOMS.map(r => {
|
|
1402
|
+
const sel = this.state.selectedRoom?.id === r.id;
|
|
1403
|
+
return `<div class="room-card ${sel ? 'selected' : ''}" onclick="window.bookingWidgetInstance.selectRoom("${String(r.id).replace(/"/g, '"')}")" style="flex: 0 0 280px; min-width: 280px;">
|
|
1404
|
+
<div class="room-card-img-wrap">
|
|
1405
|
+
<img class="room-card-img" src="${r.image || ''}" alt="${(r.name || '').replace(/"/g, '"')}" onerror="this.src='https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';" />
|
|
1406
|
+
${sel ? `<div class="room-card-check">${iconHTML('check', '1em')}</div>` : ''}
|
|
1407
|
+
</div>
|
|
1408
|
+
<div class="room-card-body">
|
|
1409
|
+
<div class="room-card-top">
|
|
1410
|
+
<span class="room-card-name">${(r.name || '').replace(/</g, '<')}</span>
|
|
1411
|
+
<div class="room-card-price"><strong>${formatRoomPrice(r)}</strong><small>/ night</small></div>
|
|
1412
|
+
</div>
|
|
1413
|
+
<p class="room-card-desc">${(r.description || '').replace(/</g, '<')}</p>
|
|
1414
|
+
<div class="room-card-meta"><span>${iconHTML('square', '0.9em')} ${r.size || ''}</span><span>${iconHTML('user', '0.9em')} Up to ${r.maxGuests != null ? r.maxGuests : ''}</span></div>
|
|
1415
|
+
<div class="amenity-tags">${(r.amenities || []).slice(0, 5).map(a => `<span class="amenity-tag">${String(a).replace(/</g, '<')}</span>`).join('')}</div>
|
|
1416
|
+
</div>
|
|
1417
|
+
</div>`;
|
|
1418
|
+
}).join('');
|
|
1419
|
+
return `
|
|
1420
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
1421
|
+
<p class="step-subtitle">Each space is crafted for an unforgettable experience</p>
|
|
1422
|
+
<div class="room-grid-wrapper">
|
|
1423
|
+
<button class="room-nav-btn room-nav-prev" onclick="window.bookingWidgetInstance.scrollRooms(-1)" aria-label="Previous rooms">${iconHTML('chevronLeft', '1.5em')}</button>
|
|
1424
|
+
<div class="room-grid" id="room-grid-${this.options.containerId}">${cards}</div>
|
|
1425
|
+
<button class="room-nav-btn room-nav-next" onclick="window.bookingWidgetInstance.scrollRooms(1)" aria-label="Next rooms">${iconHTML('chevronRight', '1.5em')}</button>
|
|
1426
|
+
</div>
|
|
1427
|
+
<button class="btn-primary" ${this.state.selectedRoom ? '' : 'disabled'} onclick="window.bookingWidgetInstance.goToStep("rates")">Select Rate</button>`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
scrollRooms(direction) {
|
|
1431
|
+
const grid = this.widget.querySelector('.room-grid');
|
|
1432
|
+
if (!grid) return;
|
|
1433
|
+
const scrollAmount = 300; // Scroll by approximately one card width + gap
|
|
1434
|
+
grid.scrollBy({
|
|
1435
|
+
left: direction * scrollAmount,
|
|
1436
|
+
behavior: 'smooth'
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
selectRoom(id) {
|
|
1441
|
+
const grid = this.widget.querySelector('.room-grid');
|
|
1442
|
+
const scrollLeft = grid ? grid.scrollLeft : 0;
|
|
1443
|
+
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
1444
|
+
this.state.selectedRoom = this.ROOMS.find(r => r.id == id || String(r.id) === String(id)) || null;
|
|
1445
|
+
this.render();
|
|
1446
|
+
const restoreScroll = () => {
|
|
1447
|
+
const newGrid = this.widget.querySelector('.room-grid');
|
|
1448
|
+
if (newGrid && scrollLeft > 0) {
|
|
1449
|
+
const prevBehavior = newGrid.style.scrollBehavior;
|
|
1450
|
+
newGrid.style.scrollBehavior = 'auto';
|
|
1451
|
+
newGrid.scrollLeft = scrollLeft;
|
|
1452
|
+
newGrid.style.scrollBehavior = prevBehavior || '';
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
requestAnimationFrame(() => {
|
|
1456
|
+
restoreScroll();
|
|
1457
|
+
requestAnimationFrame(restoreScroll);
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
renderRatesStep() {
|
|
1462
|
+
const nights = this.getNights();
|
|
1463
|
+
const base = this.state.selectedRoom?.basePrice ?? 0;
|
|
1464
|
+
let cards = this.RATES.map(r => {
|
|
1465
|
+
const sel = this.state.selectedRate?.id === r.id;
|
|
1466
|
+
const total = Math.round(base * r.priceModifier * nights * this.state.rooms);
|
|
1467
|
+
return `<div class="rate-card ${sel ? 'selected' : ''}" onclick="window.bookingWidgetInstance.selectRate("${String(r.id).replace(/"/g, '"')}")">
|
|
1468
|
+
${r.recommended ? `<div class="rate-badge">${iconHTML('star', '0.9em')} Recommended</div>` : ''}
|
|
1469
|
+
<div class="rate-top">
|
|
1470
|
+
<div class="rate-top-left">
|
|
1471
|
+
<div class="rate-radio"><div class="rate-radio-dot"></div></div>
|
|
1472
|
+
<span class="rate-name">${[r.policy, ...(r.benefits || [])].filter(Boolean).join(' ')}</span>
|
|
1473
|
+
<div class="rate-benefits"><span class="amenity-tag">${r.rate_code ?? r.name}</span></div>
|
|
1474
|
+
</div>
|
|
1475
|
+
<div class="rate-price"><strong>$ ${total.toLocaleString()}</strong><small>total</small></div>
|
|
1476
|
+
</div>
|
|
1477
|
+
<p class="rate-desc">${r.description}</p>
|
|
1478
|
+
</div>`;
|
|
1479
|
+
}).join('');
|
|
1480
|
+
const room = this.state.selectedRoom;
|
|
1481
|
+
const fallbackImg = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';
|
|
1482
|
+
const roomSummary = room ? `
|
|
1483
|
+
<div class="rate-step-room-summary">
|
|
1484
|
+
<img class="rate-step-room-summary-image" src="${room.image || fallbackImg}" alt="" onerror="this.src='${fallbackImg}'" />
|
|
1485
|
+
<div class="rate-step-room-summary-body">
|
|
1486
|
+
<h3 class="rate-step-room-summary-name">${room.name || ''}</h3>
|
|
1487
|
+
${room.description ? `<p class="rate-step-room-summary-desc">${room.description}</p>` : ''}
|
|
1488
|
+
<div class="rate-step-room-summary-meta">
|
|
1489
|
+
${room.size ? `<span>${room.size}</span>` : ''}
|
|
1490
|
+
${room.maxGuests != null ? `<span>Up to ${room.maxGuests} guests</span>` : ''}
|
|
1491
|
+
</div>
|
|
1492
|
+
</div>
|
|
1493
|
+
</div>` : '';
|
|
1494
|
+
return `
|
|
1495
|
+
<div class="rate-step-card">
|
|
1496
|
+
${roomSummary}
|
|
1497
|
+
<div class="rate-list">${cards}</div>
|
|
1498
|
+
</div>
|
|
1499
|
+
<button class="btn-primary" ${this.state.selectedRate ? '' : 'disabled'} onclick="window.bookingWidgetInstance.goToStep("summary")">Proceed to Summary</button>`;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
selectRate(id) {
|
|
1503
|
+
this.state.selectedRate = this.RATES.find(r => r.id == id || String(r.id) === String(id)) || null;
|
|
1504
|
+
this.render();
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
renderSummaryStep() {
|
|
1508
|
+
const g = this.state.guest;
|
|
1509
|
+
const total = this.getTotalPrice();
|
|
1510
|
+
const canSubmit = g.firstName && g.lastName && g.email;
|
|
1511
|
+
const buttonLabel = this.hasStripe ? 'Proceed to Payment' : 'Confirm Reservation';
|
|
1512
|
+
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>
|
|
1513
|
+
<div class="form-row">
|
|
1514
|
+
<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>
|
|
1515
|
+
<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>
|
|
1516
|
+
</div>
|
|
1517
|
+
<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>
|
|
1518
|
+
<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>
|
|
1519
|
+
<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>`;
|
|
1520
|
+
return `
|
|
1521
|
+
<h2 class="step-title">Review Your Booking</h2>
|
|
1522
|
+
<p class="step-subtitle">Confirm your details before payment</p>
|
|
1523
|
+
<div class="checkout-grid">
|
|
1524
|
+
<div>${leftColumn}</div>
|
|
1525
|
+
<div>
|
|
1526
|
+
<div class="summary-box">
|
|
1527
|
+
<h3>Booking Summary</h3>
|
|
1528
|
+
${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>` : ''}
|
|
1529
|
+
<div class="summary-row"><span>Check-in</span><span>${this.fmt(this.state.checkIn)}</span></div>
|
|
1530
|
+
<div class="summary-row"><span>Check-out</span><span>${this.fmt(this.state.checkOut)}</span></div>
|
|
1531
|
+
<div class="summary-row"><span>Guests</span><span>${(() => { const gt = this.getTotalGuests(); return gt.adults + ' adults' + (gt.children ? ', ' + gt.children + ' children' : ''); })()}</span></div>
|
|
1532
|
+
<div class="summary-row"><span>Rate</span><span>${this.state.selectedRate ? [this.state.selectedRate.policy, ...(this.state.selectedRate.benefits || [])].filter(Boolean).join(' ') : '—'}</span></div>
|
|
1533
|
+
${((this.state.selectedRate?.fees ?? []).length || this.state.selectedRate?.vat) ? (() => {
|
|
1534
|
+
const nights = this.getNights();
|
|
1535
|
+
const rooms = this.state.rooms;
|
|
1536
|
+
const roomTotal = Math.round(this.state.selectedRoom.basePrice * this.state.selectedRate.priceModifier * nights * rooms);
|
|
1537
|
+
const fees = this.state.selectedRate.fees ?? [];
|
|
1538
|
+
const allIncluded = fees.length > 0 && fees.every(f => f.included) && (!this.state.selectedRate.vat || this.state.selectedRate.vat.included);
|
|
1539
|
+
let rows = fees.map(f => {
|
|
1540
|
+
const amt = f.perNight ? f.amount * nights * rooms : f.amount;
|
|
1541
|
+
const badge = f.included ? '<span class="summary-fee-badge">Included</span>' : '<span class="summary-fee-badge summary-fee-badge--excluded">Excluded</span>';
|
|
1542
|
+
return `<div class="summary-row summary-row--fee"><span class="summary-fee-label">${f.name} ${badge}</span><span>$ ` + Math.round(amt).toLocaleString() + `</span></div>`;
|
|
1543
|
+
}).join('');
|
|
1544
|
+
const vat = this.state.selectedRate.vat;
|
|
1545
|
+
if (vat) {
|
|
1546
|
+
const showVatBadge = vat.value !== 0 && vat.value != null;
|
|
1547
|
+
const vatBadge = showVatBadge ? (vat.included ? '<span class="summary-fee-badge">Included</span>' : '<span class="summary-fee-badge summary-fee-badge--excluded">Excluded</span>') : '';
|
|
1548
|
+
rows += `<div class="summary-row summary-row--fee"><span class="summary-fee-label">VAT${vatBadge ? ' ' + vatBadge : ''}</span><span>$ ` + Math.round(vat.value || 0).toLocaleString() + `</span></div>`;
|
|
1549
|
+
}
|
|
1550
|
+
return `<div class="summary-row"><span>Room total</span><span>$ ` + Math.round(roomTotal).toLocaleString() + `</span></div>
|
|
1551
|
+
<div class="summary-fees">
|
|
1552
|
+
<p class="summary-fees-heading">Fees & taxes</p>
|
|
1553
|
+
${allIncluded ? '<p class="summary-fees-note">Included in your rate</p>' : ''}
|
|
1554
|
+
<div class="summary-fees-list">${rows}</div>
|
|
1555
|
+
</div>`;
|
|
1556
|
+
})() : ''}
|
|
1557
|
+
<div class="summary-total">
|
|
1558
|
+
<span class="summary-total-label">Total</span>
|
|
1559
|
+
<span class="summary-total-price">$ ${total.toLocaleString()}</span>
|
|
1560
|
+
</div>
|
|
1561
|
+
${this.apiError ? `<p style="color:var(--destructive, #ef4444);font-size:0.85em;margin-top:0.5em;">${this.apiError}</p>` : ''}
|
|
1562
|
+
<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>
|
|
1563
|
+
<p class="secure-note">${iconHTML('lock', '1em')} Secure & encrypted booking</p>
|
|
1564
|
+
</div>
|
|
1565
|
+
</div>
|
|
1566
|
+
</div>`;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
renderPaymentStep() {
|
|
1570
|
+
const total = this.getTotalPrice();
|
|
1571
|
+
const buttonDisabled = !this.paymentElementReady ? 'disabled' : '';
|
|
1572
|
+
const buttonLabel = !this.paymentElementReady ? 'Loading payment…' : 'Confirm Reservation';
|
|
1573
|
+
return `
|
|
1574
|
+
<div class="payment-step">
|
|
1575
|
+
<button type="button" class="payment-step-back" onclick="window.bookingWidgetInstance.goToStep("summary")" aria-label="Back to summary">${iconHTML('chevronLeft', '1em')} Back to summary</button>
|
|
1576
|
+
<h2 class="step-title">Payment</h2>
|
|
1577
|
+
<p class="step-subtitle">Enter your payment details to confirm your reservation</p>
|
|
1578
|
+
<div class="checkout-grid">
|
|
1579
|
+
<div class="payment-step-form">
|
|
1580
|
+
<div class="checkout-payment-section">
|
|
1581
|
+
<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>
|
|
1582
|
+
${!this.paymentElementReady && !this.apiError ? '<p class="payment-loading-placeholder" style="font-size:0.85em;color:var(--muted);margin:0;">Loading payment form…</p>' : ''}
|
|
1583
|
+
<div id="booking-widget-payment-element" class="booking-widget-payment-element"></div>
|
|
1584
|
+
${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>` : ''}
|
|
1585
|
+
</div>
|
|
1586
|
+
</div>
|
|
1587
|
+
<div class="payment-step-summary">
|
|
1588
|
+
<h3>Amount due</h3>
|
|
1589
|
+
<div class="payment-total-row">
|
|
1590
|
+
<span class="payment-total-label">Total</span>
|
|
1591
|
+
<span class="payment-total-amount">$ ${total.toLocaleString()}</span>
|
|
1592
|
+
</div>
|
|
1593
|
+
<button type="button" class="btn-primary" ${buttonDisabled} onclick="window.bookingWidgetInstance.confirmReservation(event)">${iconHTML('creditCard', '1.2em')} ${buttonLabel}</button>
|
|
1594
|
+
<p class="secure-note">${iconHTML('lock', '1em')} Secure & encrypted booking</p>
|
|
1595
|
+
</div>
|
|
1596
|
+
</div>
|
|
1597
|
+
</div>`;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
updateGuest(field, value) {
|
|
1601
|
+
this.state.guest[field] = value;
|
|
1602
|
+
this.render();
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
loadStripePaymentElement() {
|
|
1606
|
+
if (this.state.step !== 'payment' || !this.checkoutShowPaymentForm || !this.options.stripePublishableKey || typeof this.options.createPaymentIntent !== 'function') return;
|
|
1607
|
+
const buildPaymentIntentPayload = typeof window !== 'undefined' && window.buildPaymentIntentPayload;
|
|
1608
|
+
const buildCheckoutPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
|
|
1609
|
+
const paymentIntentPayload = buildPaymentIntentPayload ? buildPaymentIntentPayload(this.state, { propertyKey: this.options.propertyKey ?? undefined }) : (buildCheckoutPayload ? buildCheckoutPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null);
|
|
1610
|
+
if (!paymentIntentPayload) return;
|
|
1611
|
+
const self = this;
|
|
1612
|
+
this.paymentElementReady = false;
|
|
1613
|
+
Promise.resolve(this.options.createPaymentIntent(paymentIntentPayload))
|
|
1614
|
+
.then((res) => {
|
|
1615
|
+
const clientSecret = res?.clientSecret ?? res?.client_secret ?? res?.data?.clientSecret ?? res?.data?.client_secret ?? res?.paymentIntent?.client_secret;
|
|
1616
|
+
self.paymentIntentConfirmationToken = res?.confirmationToken ?? res?.confirmation_token ?? res?.data?.confirmationToken ?? res?.data?.confirmation_token;
|
|
1617
|
+
if (!clientSecret || self.state.step !== 'payment') {
|
|
1618
|
+
self.apiError = 'Payment setup failed: no client secret returned';
|
|
1619
|
+
self.render();
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const ensureLoadStripe = function () {
|
|
1623
|
+
if (typeof window !== 'undefined' && typeof window.loadStripe === 'function') return Promise.resolve(window.loadStripe);
|
|
1624
|
+
if (typeof window === 'undefined') return Promise.resolve(null);
|
|
1625
|
+
return new Promise(function (resolve) {
|
|
1626
|
+
if (window.loadStripe) { resolve(window.loadStripe); return; }
|
|
1627
|
+
window.__bookingWidgetStripeReady = function () { resolve(window.loadStripe || null); };
|
|
1628
|
+
var s = document.createElement('script');
|
|
1629
|
+
s.type = 'module';
|
|
1630
|
+
s.textContent = "import('https://esm.sh/@stripe/stripe-js').then(function(m){ window.loadStripe = m.loadStripe; if(window.__bookingWidgetStripeReady) window.__bookingWidgetStripeReady(); });";
|
|
1631
|
+
s.onerror = function () { resolve(null); };
|
|
1632
|
+
document.head.appendChild(s);
|
|
1633
|
+
});
|
|
1634
|
+
};
|
|
1635
|
+
return ensureLoadStripe().then(function (loadStripe) {
|
|
1636
|
+
if (typeof loadStripe !== 'function') {
|
|
1637
|
+
self.apiError = 'Stripe not loaded';
|
|
1638
|
+
self.render();
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
return loadStripe(self.options.stripePublishableKey).then((stripe) => {
|
|
1642
|
+
if (!stripe || self.state.step !== 'payment') return;
|
|
1643
|
+
self.stripeInstance = stripe;
|
|
1644
|
+
const elements = stripe.elements({ clientSecret, appearance: { theme: 'flat', variables: { borderRadius: '8px' } } });
|
|
1645
|
+
self.elementsInstance = elements;
|
|
1646
|
+
const paymentElement = elements.create('payment');
|
|
1647
|
+
return new Promise((r) => requestAnimationFrame(r)).then(() => {
|
|
1648
|
+
const container = document.getElementById('booking-widget-payment-element');
|
|
1649
|
+
if (!container || self.state.step !== 'payment') {
|
|
1650
|
+
self.apiError = 'Payment form container not found';
|
|
1651
|
+
self.render();
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
self.apiError = null;
|
|
1655
|
+
container.innerHTML = '';
|
|
1656
|
+
paymentElement.mount(container);
|
|
1657
|
+
self.paymentElementReady = true;
|
|
1658
|
+
self._updateCheckoutPaymentUI();
|
|
1659
|
+
});
|
|
1660
|
+
});
|
|
1661
|
+
}).then(function () {});
|
|
1662
|
+
})
|
|
1663
|
+
.catch((err) => {
|
|
1664
|
+
this.apiError = (err && err.message) || err || 'Payment setup failed';
|
|
1665
|
+
this.render();
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
_updateCheckoutPaymentUI() {
|
|
1670
|
+
if (!this.widget) return;
|
|
1671
|
+
const stepEl = this.widget.querySelector('.booking-widget-step-content');
|
|
1672
|
+
if (!stepEl) return;
|
|
1673
|
+
const loadingEl = stepEl.querySelector('.payment-loading-placeholder');
|
|
1674
|
+
if (loadingEl) loadingEl.style.display = 'none';
|
|
1675
|
+
const btn = stepEl.querySelector('.btn-primary');
|
|
1676
|
+
if (btn) {
|
|
1677
|
+
btn.disabled = false;
|
|
1678
|
+
btn.innerHTML = iconHTML('creditCard', '1.2em') + ' Confirm Reservation';
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
confirmReservation(ev) {
|
|
1683
|
+
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
|
|
1684
|
+
if (ev && typeof ev.stopPropagation === 'function') ev.stopPropagation();
|
|
1685
|
+
const canSubmit = this.state.guest.firstName && this.state.guest.lastName && this.state.guest.email;
|
|
1686
|
+
if (!canSubmit) return;
|
|
1687
|
+
const buildPayload = typeof window !== 'undefined' && window.buildCheckoutPayload;
|
|
1688
|
+
const payload = buildPayload ? buildPayload(this.state, { propertyId: this.options.propertyId ?? undefined, propertyKey: this.options.propertyKey ?? undefined }) : null;
|
|
1689
|
+
const hasStripe = this.hasStripe;
|
|
1690
|
+
|
|
1691
|
+
if (this.state.step === 'summary' && hasStripe) {
|
|
1692
|
+
this.apiError = null;
|
|
1693
|
+
this.goToStep('payment');
|
|
1694
|
+
const self = this;
|
|
1695
|
+
requestAnimationFrame(() => { requestAnimationFrame(() => self.loadStripePaymentElement()); });
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
if (this.state.step === 'payment' && hasStripe && this.stripeInstance && this.elementsInstance && payload) {
|
|
1700
|
+
this.apiError = null;
|
|
1701
|
+
this.stripeInstance.confirmPayment({
|
|
1702
|
+
elements: this.elementsInstance,
|
|
1703
|
+
confirmParams: { return_url: typeof window !== 'undefined' ? window.location.origin + (window.location.pathname || '') : '' },
|
|
1704
|
+
redirect: 'if_required',
|
|
1705
|
+
}).then(({ error, paymentIntent }) => {
|
|
1706
|
+
if (error) {
|
|
1707
|
+
this.apiError = error && error.message ? error.message : 'Payment failed';
|
|
1708
|
+
this.render();
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (paymentIntent && (paymentIntent.status === 'succeeded' || paymentIntent.status === 'requires_capture')) {
|
|
1712
|
+
if (!this.paymentIntentConfirmationToken) {
|
|
1713
|
+
this.apiError = 'Missing confirmation token from payment intent';
|
|
1714
|
+
this.render();
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
this.startConfirmationPolling(this.paymentIntentConfirmationToken);
|
|
1718
|
+
}
|
|
1719
|
+
}).catch((err) => {
|
|
1720
|
+
this.apiError = (err && err.message) || err || 'Payment failed';
|
|
1721
|
+
this.render();
|
|
1722
|
+
});
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const onBeforeConfirm = this.options.onBeforeConfirm;
|
|
1727
|
+
if (typeof onBeforeConfirm === 'function' && payload) {
|
|
1728
|
+
Promise.resolve(onBeforeConfirm(payload))
|
|
1729
|
+
.then((res) => {
|
|
1730
|
+
this.confirmationCode = (res && (res.confirmationCode != null ? res.confirmationCode : res.confirmation_code)) || ('LX' + Date.now().toString(36).toUpperCase().slice(-6));
|
|
1731
|
+
this.state.step = 'confirmation';
|
|
1732
|
+
this.render();
|
|
1733
|
+
})
|
|
1734
|
+
.catch((err) => {
|
|
1735
|
+
this.apiError = (err && err.message) || err || 'Booking failed';
|
|
1736
|
+
this.render();
|
|
1737
|
+
});
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
this.apiError = 'Configure onBeforeConfirm to submit the booking.';
|
|
1741
|
+
this.render();
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
async fetchConfirmationDetails(confirmationToken) {
|
|
1745
|
+
const token = String(confirmationToken || '').trim();
|
|
1746
|
+
if (!token) throw new Error('Missing confirmation token');
|
|
1747
|
+
const base = String(this.options.confirmationBaseUrl || 'https://ai.thehotelplanet.com').replace(/\/$/, '');
|
|
1748
|
+
const url = base + '/proxy/confirmation/' + encodeURIComponent(token);
|
|
1749
|
+
const res = await fetch(url, { method: 'POST' });
|
|
1750
|
+
if (!res.ok) throw new Error(await res.text());
|
|
1751
|
+
return await res.json();
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
startConfirmationPolling(confirmationToken) {
|
|
1755
|
+
this.confirmationToken = String(confirmationToken || '').trim();
|
|
1756
|
+
this.confirmationDetails = null;
|
|
1757
|
+
this.confirmationStatus = 'pending';
|
|
1758
|
+
this.confirmationPolling = true;
|
|
1759
|
+
this.apiError = null;
|
|
1760
|
+
this.state.step = 'confirmation';
|
|
1761
|
+
this.render();
|
|
1762
|
+
|
|
1763
|
+
if (this._confirmationPollTimer) clearTimeout(this._confirmationPollTimer);
|
|
1764
|
+
this._confirmationPollCancelled = false;
|
|
1765
|
+
const self = this;
|
|
1766
|
+
const pollOnce = function () {
|
|
1767
|
+
if (self._confirmationPollCancelled) return;
|
|
1768
|
+
self.fetchConfirmationDetails(self.confirmationToken)
|
|
1769
|
+
.then(function (data) {
|
|
1770
|
+
if (self._confirmationPollCancelled) return;
|
|
1771
|
+
const status = String(data && (data.status ?? data.booking_status ?? data.state ?? '')).toLowerCase();
|
|
1772
|
+
self.confirmationStatus = status || self.confirmationStatus || 'pending';
|
|
1773
|
+
if (status === 'confirmed') {
|
|
1774
|
+
self.confirmationDetails = data;
|
|
1775
|
+
self.confirmationPolling = false;
|
|
1776
|
+
self.render();
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
self._confirmationPollTimer = setTimeout(pollOnce, 2000);
|
|
1780
|
+
})
|
|
1781
|
+
.catch(function (err) {
|
|
1782
|
+
if (self._confirmationPollCancelled) return;
|
|
1783
|
+
self.apiError = (err && err.message) || err || 'Failed to fetch confirmation';
|
|
1784
|
+
self.render();
|
|
1785
|
+
self._confirmationPollTimer = setTimeout(pollOnce, 2000);
|
|
1786
|
+
});
|
|
1787
|
+
};
|
|
1788
|
+
pollOnce();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
renderConfirmationStep() {
|
|
1792
|
+
const details = this.confirmationDetails;
|
|
1793
|
+
const showLoader = this.confirmationPolling || !details;
|
|
1794
|
+
if (showLoader) {
|
|
1795
|
+
const status = this.confirmationStatus ? String(this.confirmationStatus) : '';
|
|
1796
|
+
return '<div class="confirmation"><div class="confirmation-loader"><div class="confirmation-loader-spinner"></div><h2 class="step-title">Finalizing Reservation</h2><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>' + (status ? '<span style="font-size:0.8em;color:var(--muted);">Status: ' + String(status).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + '</span>' : '') + (this.apiError ? '<p style="color:var(--destructive,#ef4444);font-size:0.85em;margin-top:0.5em;">' + String(this.apiError).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + '</p>' : '') + '</div></div>';
|
|
1797
|
+
}
|
|
1798
|
+
const nights = details.nights != null ? details.nights : this.getNights();
|
|
1799
|
+
const nightsLabel = nights === 1 ? '1 night' : nights + ' nights';
|
|
1800
|
+
const total = (details.totalAmount != null && details.totalAmount !== '') ? Number(details.totalAmount) : this.getTotalPrice();
|
|
1801
|
+
const currency = details.currency || this.state.selectedRoom?.currency || 'USD';
|
|
1802
|
+
const bookingId = details.bookingId ?? details.booking_id ?? null;
|
|
1803
|
+
const hotelName = details.hotelName ?? details.hotel_name ?? null;
|
|
1804
|
+
const roomTypeRaw = details.roomType ?? details.room_type ?? this.state.selectedRoom?.name ?? '';
|
|
1805
|
+
const roomType = String(roomTypeRaw).replace(/\s*Rate\s*\d+\s*$/i, '').trim();
|
|
1806
|
+
const checkInD = details.checkIn ? (typeof details.checkIn === 'string' ? new Date(details.checkIn) : details.checkIn) : this.state.checkIn;
|
|
1807
|
+
const checkOutD = details.checkOut ? (typeof details.checkOut === 'string' ? new Date(details.checkOut) : details.checkOut) : this.state.checkOut;
|
|
1808
|
+
const checkInStr = checkInD ? this.fmtLong(checkInD) : '';
|
|
1809
|
+
const checkOutStr = checkOutD ? this.fmtLong(checkOutD) : '';
|
|
1810
|
+
const esc = function (v) { return String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
|
1811
|
+
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">' + esc(bookingId) + '</span></div></div>' : '';
|
|
1812
|
+
const hotelRow = hotelName ? '<div class="confirm-detail"><span class="confirm-detail-icon">' + iconHTML('mapPin', '1.2em') + '</span><div class="confirm-detail-content"><span>' + esc(hotelName) + '</span></div></div>' : '';
|
|
1813
|
+
const fmtPrice = (typeof window.formatPrice === 'function') ? window.formatPrice : function (a, c) { return '$ ' + Math.round(Number(a) || 0).toLocaleString(); };
|
|
1814
|
+
return '<div class="confirmation"><div class="confirm-icon">' + iconHTML('check', '3.5em') + '</div><h2 class="step-title">Reservation Confirmed</h2><p class="step-subtitle">Thank you, ' + esc(this.state.guest.firstName) + '. We look forward to welcoming you.</p><div class="confirm-box">' + bookingIdBlock + '<div class="confirm-detail"><span class="confirm-detail-icon">' + iconHTML('calendar', '1.2em') + '</span><div class="confirm-detail-content"><span>' + esc(checkInStr) + ' to ' + esc(checkOutStr) + '</span><small>' + esc(nightsLabel) + '</small></div></div>' + hotelRow + '<div class="confirm-detail"><span class="confirm-detail-icon">' + iconHTML('mapPin', '1.2em') + '</span><div class="confirm-detail-content"><span>' + esc(roomType) + '</span>' + (this.state.selectedRate ? '<span class="confirm-detail-rate-line">' + esc([this.state.selectedRate.policy].concat(this.state.selectedRate.benefits || []).filter(Boolean).join(' · ')) + '</span>' : '') + '</div></div><div class="confirm-detail"><span class="confirm-detail-icon">' + iconHTML('phone', '1.2em') + '</span><div class="confirm-detail-content"><span>' + esc(this.state.guest.firstName + ' ' + this.state.guest.lastName) + '</span><small>' + esc(this.state.guest.email) + '</small></div></div><div class="summary-total"><span class="summary-total-label">Total Charged</span><span class="summary-total-price">' + fmtPrice(total, currency) + '</span></div></div><p style="font-size:0.75em;color:var(--muted);margin-bottom:1.5em;">A confirmation email has been sent to ' + esc(this.state.guest.email) + '</p><button class="btn-secondary" onclick="window.bookingWidgetInstance.resetBooking()">Book Another Stay</button></div>';
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
resetBooking() {
|
|
1818
|
+
this._confirmationPollCancelled = true;
|
|
1819
|
+
this.confirmationPolling = false;
|
|
1820
|
+
if (this._confirmationPollTimer) clearTimeout(this._confirmationPollTimer);
|
|
1821
|
+
this._confirmationPollTimer = null;
|
|
1822
|
+
this.confirmationCode = null;
|
|
1823
|
+
this.confirmationToken = null;
|
|
1824
|
+
this.confirmationDetails = null;
|
|
1825
|
+
this.confirmationStatus = null;
|
|
1826
|
+
this.apiError = null;
|
|
1827
|
+
Object.assign(this.state, {
|
|
1828
|
+
step: 'dates',
|
|
1829
|
+
checkIn: null,
|
|
1830
|
+
checkOut: null,
|
|
1831
|
+
rooms: 1,
|
|
1832
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
1833
|
+
selectedRoom: null,
|
|
1834
|
+
selectedRate: null,
|
|
1835
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' }
|
|
1836
|
+
});
|
|
1837
|
+
if (this.options.onComplete) {
|
|
1838
|
+
this.options.onComplete(this.state);
|
|
1839
|
+
}
|
|
1840
|
+
this.render();
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Export to global scope
|
|
1845
|
+
if (typeof window !== 'undefined') {
|
|
1846
|
+
window.BookingWidget = BookingWidget;
|
|
1847
|
+
}
|
|
1848
|
+
})();
|