@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,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Booking API layer for the @nuitee/booking-widget.
|
|
3
|
+
* Used by Vanilla JS, Vue, and React variants so integration lives in one place.
|
|
4
|
+
*
|
|
5
|
+
* API contract (adapt your backend to this shape or use transform in the methods below):
|
|
6
|
+
*
|
|
7
|
+
* GET /rooms?checkIn=ISO&checkOut=ISO&adults=N&children=N&rooms=N
|
|
8
|
+
* Response: Array<{ id, name, description, image, size, maxGuests, amenities[], basePrice }>
|
|
9
|
+
*
|
|
10
|
+
* GET /rates?roomId=ID&checkIn=ISO&checkOut=ISO&adults=N&children=N&rooms=N
|
|
11
|
+
* Response: Array<{ id, name, description, priceModifier, benefits[], recommended? }>
|
|
12
|
+
*
|
|
13
|
+
* POST /bookings
|
|
14
|
+
* Body: { checkIn, checkOut, adults, children, rooms, roomId, rateId, guest: { firstName, lastName, email, phone, specialRequests } }
|
|
15
|
+
* Response: { confirmationCode [, ...] }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_ROOMS = [];
|
|
19
|
+
|
|
20
|
+
const DEFAULT_RATES = [];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns date as YYYY-MM-DD using local date (no timezone shift).
|
|
24
|
+
* Calendar dates are created at local midnight; using toISOString() would convert to UTC and can shift the day.
|
|
25
|
+
*/
|
|
26
|
+
function toISO(date) {
|
|
27
|
+
if (!date) return '';
|
|
28
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
29
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
30
|
+
const y = d.getFullYear();
|
|
31
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
32
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
33
|
+
return `${y}-${m}-${day}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Maps currency code (e.g. 'EUR') to symbol (e.g. '€'). Falls back to code if unknown. */
|
|
37
|
+
const CURRENCY_SYMBOLS = {
|
|
38
|
+
USD: '$', EUR: '€', GBP: '£', JPY: '¥', CHF: 'Fr', CAD: 'C$', AUD: 'A$',
|
|
39
|
+
MAD: 'MAD', IDR: 'Rp', INR: '₹', THB: '฿', MXN: 'MX$', BRL: 'R$',
|
|
40
|
+
CNY: '¥', KRW: '₩', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', CZK: 'Kč',
|
|
41
|
+
HUF: 'Ft', RON: 'lei', BGN: 'лв', TRY: '₺', ZAR: 'R', AED: 'د.إ', SAR: '﷼',
|
|
42
|
+
};
|
|
43
|
+
function getCurrencySymbol(code) {
|
|
44
|
+
if (!code || typeof code !== 'string') return '$';
|
|
45
|
+
const c = code.trim().toUpperCase();
|
|
46
|
+
return CURRENCY_SYMBOLS[c] ?? c + ' ';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Format price with currency symbol and space (e.g. formatPrice(1234, 'EUR') → '€ 1,234') */
|
|
50
|
+
function formatPrice(amount, currencyCode = 'USD') {
|
|
51
|
+
const symbol = getCurrencySymbol(currencyCode).replace(/\s+$/, '');
|
|
52
|
+
const n = Number(amount);
|
|
53
|
+
if (Number.isNaN(n)) return symbol + ' 0';
|
|
54
|
+
return symbol + ' ' + Math.round(n).toLocaleString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract user-facing error message from API response body (error, message, etc.) */
|
|
58
|
+
function getErrorMessage(body, fallback) {
|
|
59
|
+
if (body && typeof body === 'object') {
|
|
60
|
+
const msg = body.error ?? body.message ?? body.err ?? body.detail;
|
|
61
|
+
if (typeof msg === 'string') return msg;
|
|
62
|
+
if (typeof msg === 'object' && msg?.message) return msg.message;
|
|
63
|
+
}
|
|
64
|
+
return fallback || 'Request failed';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parses date from availability API (e.g. "2023-05-20 23:59:59"). Normalizes to ISO for reliable parsing.
|
|
69
|
+
*/
|
|
70
|
+
function parsePolicyUntil(val) {
|
|
71
|
+
if (val == null) return null;
|
|
72
|
+
if (val instanceof Date) return Number.isNaN(val.getTime()) ? null : val;
|
|
73
|
+
const str = String(val).trim();
|
|
74
|
+
if (!str) return null;
|
|
75
|
+
const iso = str.replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)/, '$1T$2');
|
|
76
|
+
const d = new Date(iso);
|
|
77
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Derives "Free cancellation" vs "Non-refundable" from the availability rate only.
|
|
82
|
+
* Uses only the policy array from the availability response: policy[] with { until: "YYYY-MM-DD HH:mm:ss", penalty }.
|
|
83
|
+
* If policy.length > 0 and the first until date is in the future → "Free cancellation", else "Non-refundable".
|
|
84
|
+
*/
|
|
85
|
+
function deriveRatePolicyLabel(avRate) {
|
|
86
|
+
const policy = avRate.policy;
|
|
87
|
+
if (!Array.isArray(policy) || policy.length === 0) return 'Non-refundable';
|
|
88
|
+
|
|
89
|
+
const first = policy[0];
|
|
90
|
+
const until = first?.until;
|
|
91
|
+
if (until == null) return 'Non-refundable';
|
|
92
|
+
|
|
93
|
+
const untilDate = parsePolicyUntil(until);
|
|
94
|
+
if (untilDate == null) return 'Non-refundable';
|
|
95
|
+
|
|
96
|
+
return untilDate > new Date() ? 'Free cancellation' : 'Non-refundable';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a stable key for "board" (meal plan) from rate benefits for grouping.
|
|
101
|
+
* @param {Array<string>} [benefits]
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function getBoardKey(benefits) {
|
|
105
|
+
if (!Array.isArray(benefits) || benefits.length === 0) return '';
|
|
106
|
+
return benefits
|
|
107
|
+
.map((b) => (typeof b === 'string' ? b : String(b)).trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.sort()
|
|
110
|
+
.join('|');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* From a list of rates (widget shape with policy, benefits, priceModifier), keep only the cheapest
|
|
115
|
+
* rate per (cancellation policy, board). So e.g. 4 rates → 2 free cancellation + room only, 2 non-refundable + breakfast
|
|
116
|
+
* becomes 2 rates: cheapest in each group.
|
|
117
|
+
* @param {Array<{ policy?: string, benefits?: string[], priceModifier?: number }>} rates
|
|
118
|
+
* @returns {Array} Filtered rates (same objects, no copy)
|
|
119
|
+
*/
|
|
120
|
+
function filterCheapestRatePerPolicyAndBoard(rates) {
|
|
121
|
+
if (!Array.isArray(rates) || rates.length === 0) return rates;
|
|
122
|
+
const byKey = new Map();
|
|
123
|
+
for (const r of rates) {
|
|
124
|
+
const policy = r.policy ?? 'Non-refundable';
|
|
125
|
+
const boardKey = getBoardKey(r.benefits);
|
|
126
|
+
const key = `${String(policy)}::${boardKey}`;
|
|
127
|
+
const existing = byKey.get(key);
|
|
128
|
+
const price = r.priceModifier ?? 1;
|
|
129
|
+
if (!existing || (existing.priceModifier ?? 1) > price) {
|
|
130
|
+
byKey.set(key, r);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return Array.from(byKey.values());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize rate fees from availability API into a uniform shape for the widget.
|
|
138
|
+
* API shape: [{ name, value, included? }, ...]. included: true = fee already in rate (show only); false = add to total.
|
|
139
|
+
* @param {Object} avRate - Raw rate from availability response
|
|
140
|
+
* @returns {Array<{ name: string, amount: number, included?: boolean }>}
|
|
141
|
+
*/
|
|
142
|
+
function normalizeRateFees(avRate) {
|
|
143
|
+
const list = avRate?.fees ?? (avRate?.fee != null ? [avRate.fee] : []);
|
|
144
|
+
if (!Array.isArray(list) || list.length === 0) return [];
|
|
145
|
+
return list
|
|
146
|
+
.map((f) => {
|
|
147
|
+
const name = f?.name ?? f?.label ?? f?.type ?? f?.description ?? 'Fee';
|
|
148
|
+
const amount = Number(f?.value ?? f?.amount ?? f?.price ?? 0) || 0;
|
|
149
|
+
if (amount <= 0) return null;
|
|
150
|
+
const included = Boolean(f?.included);
|
|
151
|
+
return { name: String(name), amount, included };
|
|
152
|
+
})
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Format policy "until" (e.g. "2023-05-20 23:59:59") for display in tooltip. */
|
|
157
|
+
function formatPolicyUntilForDisplay(untilStr) {
|
|
158
|
+
const d = parsePolicyUntil(untilStr);
|
|
159
|
+
if (!d) return '';
|
|
160
|
+
const datePart = d.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
161
|
+
const timePart = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
162
|
+
return `${datePart}, ${timePart}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Builds human-readable policy detail for tooltip from availability rate policy array.
|
|
167
|
+
*/
|
|
168
|
+
const NON_REFUNDABLE_POLICY_DETAIL = 'In case of cancellation, no-show or modification, the total amount of the booking is not refunded.';
|
|
169
|
+
|
|
170
|
+
function deriveRatePolicyDetail(avRate) {
|
|
171
|
+
const policy = avRate.policy;
|
|
172
|
+
if (!Array.isArray(policy) || policy.length === 0) {
|
|
173
|
+
return NON_REFUNDABLE_POLICY_DETAIL;
|
|
174
|
+
}
|
|
175
|
+
const first = policy[0];
|
|
176
|
+
const until = first?.until;
|
|
177
|
+
const penalty = first?.penalty;
|
|
178
|
+
const untilDate = until ? parsePolicyUntil(until) : null;
|
|
179
|
+
const isFree = untilDate != null && untilDate > new Date();
|
|
180
|
+
const formattedUntil = formatPolicyUntilForDisplay(until);
|
|
181
|
+
if (isFree && formattedUntil) {
|
|
182
|
+
const penaltyNote = penalty != null && penalty > 0
|
|
183
|
+
? ` After that, a penalty of ${penalty} may apply.`
|
|
184
|
+
: ' After that, a penalty may apply.';
|
|
185
|
+
return `Free cancellation until ${formattedUntil}.${penaltyNote}`;
|
|
186
|
+
}
|
|
187
|
+
return NON_REFUNDABLE_POLICY_DETAIL;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates a booking API client. Use this in all three widgets (Vanilla, Vue, React).
|
|
192
|
+
*
|
|
193
|
+
* @param {Object} config
|
|
194
|
+
* @param {string} [config.baseUrl] - Base URL for the API (e.g. 'https://api.example.com'). If omitted, fetch methods return static fallback data.
|
|
195
|
+
* @param {string} [config.availabilityBaseUrl] - Base URL for availability API (e.g. 'https://extranet.thehotelplanet.com'). Defaults to baseUrl.
|
|
196
|
+
* @param {string} [config.propertyBaseUrl] - Base URL for property/rooms API (e.g. 'https://thehotelplanet.com'). Use '' in dev for same-origin proxy.
|
|
197
|
+
* @param {string} [config.s3BaseUrl] - Base URL for room images (e.g. from VITE_AWS_S3_PATH).
|
|
198
|
+
* @param {string|number} [config.propertyId] - Property ID (legacy). Prefer propertyKey for proxy endpoints.
|
|
199
|
+
* @param {string} [config.propertyKey] - Property key for proxy endpoints (e.g. VITE_PROPERTY_KEY). Used in /proxy/availability and /proxy/ari-properties?key=.
|
|
200
|
+
* @param {string} [config.currency] - Currency code (e.g. 'MAD'). Default 'MAD'.
|
|
201
|
+
* @param {Object} [config.headers] - Optional. Static headers for each request (e.g. { 'X-API-Key': '...' }).
|
|
202
|
+
* @param {function(): Object} [config.getHeaders] - Optional. Return headers for each request (merged after headers). Use for dynamic headers.
|
|
203
|
+
* @returns {{ fetchRooms: function, fetchRates: function, createBooking: function, hasApi: boolean }}
|
|
204
|
+
*/
|
|
205
|
+
function createBookingApi(config = {}) {
|
|
206
|
+
const baseUrl = (config.baseUrl || '').replace(/\/$/, '');
|
|
207
|
+
// Empty string means "same-origin" (relative path) for proxy in dev; undefined/null fall back to baseUrl
|
|
208
|
+
const availabilityBaseUrl = config.availabilityBaseUrl === '' ? '' : ((config.availabilityBaseUrl || baseUrl) || '').replace(/\/$/, '');
|
|
209
|
+
const propertyBaseUrl = (config.propertyBaseUrl ?? '').replace(/\/$/, '');
|
|
210
|
+
const s3BaseUrl = (config.s3BaseUrl ?? '').replace(/\/$/, '');
|
|
211
|
+
const propertyId = config.propertyId != null ? String(config.propertyId) : null;
|
|
212
|
+
const rawKey = config.propertyKey != null && config.propertyKey !== '' ? String(config.propertyKey).trim() : null;
|
|
213
|
+
// Proxy endpoints expect key to start with "book_engine_" (installer's VITE_PROPERTY_KEY). Ignore other values.
|
|
214
|
+
const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : null;
|
|
215
|
+
if (rawKey && !propertyKey && typeof console !== 'undefined' && console.warn) {
|
|
216
|
+
console.warn('[booking-api] Property key must start with "book_engine_". Ignoring invalid value. Set VITE_PROPERTY_KEY in your .env (see .env.example).');
|
|
217
|
+
}
|
|
218
|
+
const currency = config.currency || 'MAD';
|
|
219
|
+
const staticHeaders = config.headers || {};
|
|
220
|
+
const getHeaders = config.getHeaders || (() => ({}));
|
|
221
|
+
|
|
222
|
+
// baseUrl can be empty string for same-origin; treat propertyKey flow as "API enabled"
|
|
223
|
+
const hasApi = Boolean(baseUrl) || Boolean(propertyKey);
|
|
224
|
+
const hasAvailabilityApi = Boolean(propertyKey || propertyId);
|
|
225
|
+
|
|
226
|
+
async function request(method, path, body, base = baseUrl) {
|
|
227
|
+
const url = `${(base || '').replace(/\/$/, '')}${path}`;
|
|
228
|
+
const headers = {
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
...staticHeaders,
|
|
231
|
+
...getHeaders(),
|
|
232
|
+
};
|
|
233
|
+
if (url.includes('ngrok')) headers['ngrok-skip-browser-warning'] = 'true';
|
|
234
|
+
const options = { method, headers };
|
|
235
|
+
if (body && (method === 'POST' || method === 'PUT')) options.body = JSON.stringify(body);
|
|
236
|
+
const res = await fetch(url, options);
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
let body;
|
|
239
|
+
try { body = await res.json(); } catch (_) {}
|
|
240
|
+
const err = new Error(getErrorMessage(body, res.statusText || 'Request failed'));
|
|
241
|
+
err.status = res.status;
|
|
242
|
+
err.response = res;
|
|
243
|
+
err.body = body;
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
const contentType = res.headers.get('content-type');
|
|
247
|
+
if (contentType && contentType.includes('application/json')) return res.json();
|
|
248
|
+
return res.text();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fetches available rooms: calls availability API, applies occupancy filter, merges with property/rooms from get_properties.
|
|
253
|
+
* @param {{ checkIn: Date|string, checkOut: Date|string, adults: number, children: number, rooms: number }} params
|
|
254
|
+
* @returns {Promise<Array>} Room list in widget shape { id, name, description, image, size, maxGuests, amenities, basePrice }
|
|
255
|
+
*/
|
|
256
|
+
async function fetchRooms(params) {
|
|
257
|
+
if (!hasAvailabilityApi) return Promise.resolve([]);
|
|
258
|
+
const rooms = params.rooms ?? 1;
|
|
259
|
+
let occupancies;
|
|
260
|
+
if (Array.isArray(params.occupancies) && params.occupancies.length >= rooms) {
|
|
261
|
+
occupancies = params.occupancies.slice(0, rooms).map((occ, i) => ({
|
|
262
|
+
adults: Math.max(1, Math.min(8, Number(occ.adults) || 1)),
|
|
263
|
+
children: Array.isArray(occ.children) ? occ.children.slice(0, 6).map(a => Math.max(0, Math.min(17, Number(a) || 0))) : [],
|
|
264
|
+
occupancy_index: i + 1,
|
|
265
|
+
}));
|
|
266
|
+
} else {
|
|
267
|
+
const adults = params.adults ?? 2;
|
|
268
|
+
const children = params.children ?? 0;
|
|
269
|
+
const childrenAges = Array.isArray(params.childrenAges) ? params.childrenAges.slice(0, children) : [];
|
|
270
|
+
while (childrenAges.length < children) childrenAges.push(0);
|
|
271
|
+
occupancies = Array.from({ length: rooms }, (_, i) => ({
|
|
272
|
+
adults: i === 0 ? adults : 1,
|
|
273
|
+
children: i === 0 ? childrenAges : [],
|
|
274
|
+
occupancy_index: i + 1,
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
const occupancyLength = occupancies.length;
|
|
278
|
+
|
|
279
|
+
// Fetch get_properties first to get currency_code (e.g. 'EUR') and room details
|
|
280
|
+
let propertyRooms = {};
|
|
281
|
+
let propertyCurrency = currency;
|
|
282
|
+
const propFullUrl = propertyKey
|
|
283
|
+
? `${availabilityBaseUrl}/proxy/ari-properties?key=${encodeURIComponent(propertyKey)}`
|
|
284
|
+
: propertyBaseUrl
|
|
285
|
+
? `${propertyBaseUrl}/ari/get_properties?id=${encodeURIComponent(propertyId)}`
|
|
286
|
+
: `/ari/get_properties?id=${encodeURIComponent(propertyId)}`;
|
|
287
|
+
try {
|
|
288
|
+
const getOpts = { method: 'GET' };
|
|
289
|
+
if (propFullUrl.includes('ngrok')) getOpts.headers = { 'ngrok-skip-browser-warning': 'true' };
|
|
290
|
+
const propRes = await fetch(propFullUrl, getOpts);
|
|
291
|
+
if (propRes.ok) {
|
|
292
|
+
const propData = await propRes.json();
|
|
293
|
+
propertyRooms = propData?.rooms ?? {};
|
|
294
|
+
propertyCurrency = (typeof propData?.currency_code === 'string' && propData.currency_code.trim())
|
|
295
|
+
? propData.currency_code.trim()
|
|
296
|
+
: currency;
|
|
297
|
+
}
|
|
298
|
+
} catch (_) {}
|
|
299
|
+
|
|
300
|
+
const body = {
|
|
301
|
+
...(propertyKey ? {} : { property_ids: [Number(propertyId)] }),
|
|
302
|
+
checkin: toISO(params.checkIn),
|
|
303
|
+
checkout: toISO(params.checkOut),
|
|
304
|
+
occupancies,
|
|
305
|
+
nationality: '',
|
|
306
|
+
currency: propertyCurrency,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const availabilityPath = propertyKey
|
|
310
|
+
? `/proxy/availability?key=${encodeURIComponent(propertyKey)}`
|
|
311
|
+
: `/api/calendar/booking_engine/${propertyId}/availability`;
|
|
312
|
+
const data = await request('POST', availabilityPath, body, availabilityBaseUrl);
|
|
313
|
+
if (data && typeof data === 'object' && (data.error || data.message)) {
|
|
314
|
+
throw new Error(getErrorMessage(data, 'Failed to load rooms'));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Extract rates: properties[0].rates or rates or data
|
|
318
|
+
const rawRates = data?.properties?.[0]?.rates ?? data?.rates ?? data?.data ?? [];
|
|
319
|
+
const ratesArray = Array.isArray(rawRates) ? rawRates : Object.values(rawRates || {});
|
|
320
|
+
|
|
321
|
+
if (ratesArray.length === 0) {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Occupancy filter: keep only room_id+rate_id combos that appear for ALL occupancies
|
|
326
|
+
const duplicateCountMap = new Map();
|
|
327
|
+
for (const r of ratesArray) {
|
|
328
|
+
const key = `${r.room_id}-${r.rate_id}`;
|
|
329
|
+
duplicateCountMap.set(key, (duplicateCountMap.get(key) || 0) + 1);
|
|
330
|
+
}
|
|
331
|
+
const uniqueRates = ratesArray.filter((r) => {
|
|
332
|
+
const key = `${r.room_id}-${r.rate_id}`;
|
|
333
|
+
return duplicateCountMap.get(key) === occupancyLength;
|
|
334
|
+
});
|
|
335
|
+
const seenKeys = new Set();
|
|
336
|
+
const filteredRates = uniqueRates.filter((r) => {
|
|
337
|
+
const key = `${r.room_id}-${r.rate_id}`;
|
|
338
|
+
if (seenKeys.has(key)) return false;
|
|
339
|
+
seenKeys.add(key);
|
|
340
|
+
return true;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const availableRoomIds = [...new Set(filteredRates.map((r) => String(r.room_id)))];
|
|
344
|
+
if (availableRoomIds.length === 0) return [];
|
|
345
|
+
|
|
346
|
+
// Map to widget room format (propertyRooms already fetched above)
|
|
347
|
+
const getPrice = (r) => {
|
|
348
|
+
const a = r?.amount ?? r;
|
|
349
|
+
return (a?.price ?? a?.total ?? r?.price ?? 0) || 0;
|
|
350
|
+
};
|
|
351
|
+
const fallbackImage = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80';
|
|
352
|
+
|
|
353
|
+
return availableRoomIds.map((roomId) => {
|
|
354
|
+
const roomData = propertyRooms[roomId] ?? propertyRooms[String(roomId)] ?? {};
|
|
355
|
+
const roomRates = filteredRates.filter((r) => String(r.room_id) === String(roomId));
|
|
356
|
+
const minPrice = roomRates.length
|
|
357
|
+
? Math.min(...roomRates.map(getPrice).filter((p) => p > 0)) || roomData.base_price || 0
|
|
358
|
+
: roomData.base_price || 0;
|
|
359
|
+
|
|
360
|
+
const photos = roomData.photos ?? [];
|
|
361
|
+
const mainPhoto = photos.find((p) => p.main) ?? photos[0];
|
|
362
|
+
const photoPath = mainPhoto?.path ?? '';
|
|
363
|
+
const image = s3BaseUrl && photoPath ? `${s3BaseUrl}/${photoPath.replace(/^\//, '')}` : fallbackImage;
|
|
364
|
+
|
|
365
|
+
const sizeVal = roomData.size?.value ?? roomData.size_value;
|
|
366
|
+
let sizeUnit = roomData.size?.unit ?? roomData.size_unit ?? 'm²';
|
|
367
|
+
if (typeof sizeUnit === 'string' && /^square_?meter(s)?$/i.test(sizeUnit.trim())) sizeUnit = 'm²';
|
|
368
|
+
const size = sizeVal != null ? `${sizeVal} ${sizeUnit}` : '';
|
|
369
|
+
|
|
370
|
+
const roomBasePrice = minPrice || 1;
|
|
371
|
+
const propertyRates = roomData.rates ?? {};
|
|
372
|
+
const rates = roomRates.map((avRate, idx) => {
|
|
373
|
+
const ratePrice = getPrice(avRate) || 0;
|
|
374
|
+
const plan = propertyRates[avRate.rate_id] ?? propertyRates[String(avRate.rate_id)] ?? {};
|
|
375
|
+
const planName = plan.name ?? avRate.name ?? `Rate ${avRate.rate_id}`;
|
|
376
|
+
const board = avRate.board ?? avRate.meal_plan;
|
|
377
|
+
const benefits = Array.isArray(board) ? board : (board ? [String(board)] : []);
|
|
378
|
+
const policyLabel = deriveRatePolicyLabel(avRate);
|
|
379
|
+
const policyDetail = deriveRatePolicyDetail(avRate);
|
|
380
|
+
const fees = normalizeRateFees(avRate);
|
|
381
|
+
const vat = avRate?.vat != null
|
|
382
|
+
? { value: Number(avRate.vat.value) || 0, included: Boolean(avRate.vat.included) }
|
|
383
|
+
: { value: 0, included: false };
|
|
384
|
+
const rateCode = avRate.rate_code ?? avRate.rate_name ?? plan.rate_code ?? plan.rate_name ?? planName;
|
|
385
|
+
const rateIdentifier = avRate.rate_identifier ?? avRate.rate_id ?? avRate.id ?? rateCode;
|
|
386
|
+
return {
|
|
387
|
+
id: avRate.rate_id ?? avRate.id ?? avRate,
|
|
388
|
+
name: planName,
|
|
389
|
+
description: plan.min_stay_length ? `Min. ${plan.min_stay_length} night(s)` : '',
|
|
390
|
+
priceModifier: ratePrice > 0 ? ratePrice / roomBasePrice : 1,
|
|
391
|
+
benefits,
|
|
392
|
+
policy: policyLabel,
|
|
393
|
+
policyDetail,
|
|
394
|
+
fees,
|
|
395
|
+
vat,
|
|
396
|
+
recommended: idx === 0,
|
|
397
|
+
rate_code: rateCode,
|
|
398
|
+
rate_identifier: rateIdentifier,
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const ratesFiltered = filterCheapestRatePerPolicyAndBoard(rates);
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
id: roomData.id ?? roomId,
|
|
406
|
+
name: roomData.name ?? `Room ${roomId}`,
|
|
407
|
+
description: roomData.description ?? '',
|
|
408
|
+
image,
|
|
409
|
+
size,
|
|
410
|
+
maxGuests: roomData.max_guests ?? roomData.max_occupancy ?? 2,
|
|
411
|
+
amenities: (() => {
|
|
412
|
+
const a = roomData.amenities;
|
|
413
|
+
if (!a) return [];
|
|
414
|
+
if (Array.isArray(a)) return a.map((x) => (typeof x === 'object' && x?.name ? x.name : String(x)));
|
|
415
|
+
return [String(a)];
|
|
416
|
+
})(),
|
|
417
|
+
basePrice: minPrice || 0,
|
|
418
|
+
currency: propertyCurrency,
|
|
419
|
+
rates: ratesFiltered,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @param {string} roomId
|
|
426
|
+
* @param {{ checkIn: Date|string, checkOut: Date|string, adults: number, children: number, rooms: number }} params
|
|
427
|
+
* @returns {Promise<Array>} Rate list in widget shape
|
|
428
|
+
*/
|
|
429
|
+
async function fetchRates(roomId, params) {
|
|
430
|
+
if (!hasApi) return Promise.resolve([]);
|
|
431
|
+
let adults = params.adults;
|
|
432
|
+
let children = params.children;
|
|
433
|
+
if (Array.isArray(params.occupancies) && params.occupancies.length > 0) {
|
|
434
|
+
adults = params.occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
435
|
+
children = params.occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
436
|
+
}
|
|
437
|
+
const q = new URLSearchParams({
|
|
438
|
+
roomId,
|
|
439
|
+
checkIn: toISO(params.checkIn),
|
|
440
|
+
checkOut: toISO(params.checkOut),
|
|
441
|
+
adults: String(adults ?? 2),
|
|
442
|
+
children: String(children ?? 0),
|
|
443
|
+
rooms: String(params.rooms ?? 1),
|
|
444
|
+
}).toString();
|
|
445
|
+
const data = await request('GET', `/rates?${q}`);
|
|
446
|
+
if (data && typeof data === 'object' && (data.error || data.message)) {
|
|
447
|
+
throw new Error(getErrorMessage(data, 'Failed to load rates'));
|
|
448
|
+
}
|
|
449
|
+
return Array.isArray(data) ? data : (data.rates || data.data || []);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @param {Object} payload - Full booking state: checkIn, checkOut, adults, children, rooms, selectedRoom (or roomId), selectedRate (or rateId), guest
|
|
454
|
+
* @returns {Promise<{ confirmationCode: string }>}
|
|
455
|
+
*/
|
|
456
|
+
async function createBooking(payload) {
|
|
457
|
+
let adults = payload.adults;
|
|
458
|
+
let children = payload.children;
|
|
459
|
+
if (Array.isArray(payload.occupancies) && payload.occupancies.length > 0) {
|
|
460
|
+
adults = payload.occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
461
|
+
children = payload.occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
462
|
+
}
|
|
463
|
+
const body = {
|
|
464
|
+
checkIn: toISO(payload.checkIn),
|
|
465
|
+
checkOut: toISO(payload.checkOut),
|
|
466
|
+
adults: adults ?? 2,
|
|
467
|
+
children: children ?? 0,
|
|
468
|
+
rooms: payload.rooms ?? 1,
|
|
469
|
+
roomId: payload.selectedRoom?.id ?? payload.roomId,
|
|
470
|
+
rateId: payload.selectedRate?.id ?? payload.rateId,
|
|
471
|
+
guest: payload.guest || {},
|
|
472
|
+
};
|
|
473
|
+
if (!hasApi) {
|
|
474
|
+
return Promise.resolve({
|
|
475
|
+
confirmationCode: 'LX' + Date.now().toString(36).toUpperCase().slice(-6),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const data = await request('POST', '/bookings', body);
|
|
479
|
+
if (data && typeof data === 'object' && (data.error || data.message)) {
|
|
480
|
+
throw new Error(getErrorMessage(data, 'Booking failed'));
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
confirmationCode: data.confirmationCode || data.confirmation_code || data.id || ('LX' + Date.now().toString(36).toUpperCase().slice(-6)),
|
|
484
|
+
...data,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
fetchRooms,
|
|
490
|
+
fetchRates,
|
|
491
|
+
createBooking,
|
|
492
|
+
hasApi,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Compute total price (same logic as widget getTotalPrice).
|
|
498
|
+
* API basePrice is total for the full stay per room; total = basePrice * priceModifier * rooms.
|
|
499
|
+
* @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
|
|
500
|
+
* @param {number} nights
|
|
501
|
+
* @returns {number}
|
|
502
|
+
*/
|
|
503
|
+
function computeCheckoutTotal(state, nights) {
|
|
504
|
+
const room = state.selectedRoom;
|
|
505
|
+
const rate = state.selectedRate;
|
|
506
|
+
if (!room || !rate) return 0;
|
|
507
|
+
const rooms = state.rooms ?? 1;
|
|
508
|
+
const roomTotal = Math.round(room.basePrice * rate.priceModifier * rooms);
|
|
509
|
+
const fees = rate.fees ?? [];
|
|
510
|
+
let add = 0;
|
|
511
|
+
fees.forEach((f) => {
|
|
512
|
+
if (!f.included) add += f.perNight ? f.amount * nights * rooms : f.amount;
|
|
513
|
+
});
|
|
514
|
+
if (rate.vat && !rate.vat.included) add += rate.vat.value || 0;
|
|
515
|
+
return Math.round(roomTotal + add);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Build payload for checkout submit: Stripe Payment Intent + external_booking + internal_booking.
|
|
520
|
+
* Use this when integrating Stripe (amount/currency for Payment Intent) and your booking API (external_booking + internal_booking; add stripe_token after payment success).
|
|
521
|
+
*
|
|
522
|
+
* @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
|
|
523
|
+
* @param {Object} [options] - { propertyId: string|number, propertyKey: string, clientBookingReference?: string }
|
|
524
|
+
* @returns {{ stripe: { amount: number, currency: string }, external_booking: Object, internal_booking: Object }}
|
|
525
|
+
*/
|
|
526
|
+
function buildCheckoutPayload(state, options = {}) {
|
|
527
|
+
const propertyId = options.propertyId != null ? String(options.propertyId) : '';
|
|
528
|
+
const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
|
|
529
|
+
const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
|
|
530
|
+
const clientBookingReference = options.clientBookingReference || 'nuitee-booking-widget';
|
|
531
|
+
|
|
532
|
+
const checkIn = state.checkIn;
|
|
533
|
+
const checkOut = state.checkOut;
|
|
534
|
+
const nights = checkIn && checkOut
|
|
535
|
+
? Math.max(1, Math.round((checkOut - checkIn) / 86400000))
|
|
536
|
+
: 0;
|
|
537
|
+
const total = computeCheckoutTotal(state, nights);
|
|
538
|
+
const currency = (state.selectedRoom?.currency || 'USD').trim();
|
|
539
|
+
const occupancies = Array.isArray(state.occupancies) ? state.occupancies : [{ adults: 2, children: 0, childrenAges: [] }];
|
|
540
|
+
const totalAdults = occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
541
|
+
const totalChildren = occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
542
|
+
|
|
543
|
+
const g = state.guest || {};
|
|
544
|
+
const availabilityRequest = {
|
|
545
|
+
...(propertyKey ? { property_key: propertyKey } : propertyId ? { property_id: propertyId } : {}),
|
|
546
|
+
checkin: toISO(checkIn),
|
|
547
|
+
checkout: toISO(checkOut),
|
|
548
|
+
currency: currency.toUpperCase(),
|
|
549
|
+
occupancies: occupancies.map((o, i) => ({
|
|
550
|
+
adults: o.adults ?? 0,
|
|
551
|
+
children: o.children ?? 0,
|
|
552
|
+
...(Array.isArray(o.childrenAges) && o.childrenAges.length ? { child_ages: o.childrenAges } : {}),
|
|
553
|
+
...(i > 0 ? { occupancy_index: i + 1 } : {}),
|
|
554
|
+
})),
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const rateIdentifier = state.selectedRate?.rate_identifier ?? state.selectedRate?.rate_code ?? state.selectedRate?.id;
|
|
558
|
+
|
|
559
|
+
const external_booking = {
|
|
560
|
+
availability_request: availabilityRequest,
|
|
561
|
+
booked_rates: [
|
|
562
|
+
{
|
|
563
|
+
quantity: state.rooms ?? 1,
|
|
564
|
+
rate_identifier: rateIdentifier,
|
|
565
|
+
amount: { price: total, currency: currency.toUpperCase() },
|
|
566
|
+
guest_details: [
|
|
567
|
+
{ leader: true, first_name: g.firstName || '', last_name: g.lastName || '' },
|
|
568
|
+
],
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
client_booking_reference: clientBookingReference,
|
|
572
|
+
};
|
|
573
|
+
if (g.specialRequests != null && String(g.specialRequests).trim() !== '') {
|
|
574
|
+
external_booking.customer_remarks = String(g.specialRequests).trim();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const internal_booking = {
|
|
578
|
+
email: g.email || '',
|
|
579
|
+
first_name: g.firstName || '',
|
|
580
|
+
last_name: g.lastName || '',
|
|
581
|
+
phone: g.phone || '',
|
|
582
|
+
checkin: toISO(checkIn),
|
|
583
|
+
checkout: toISO(checkOut),
|
|
584
|
+
stays: nights,
|
|
585
|
+
price: total,
|
|
586
|
+
currency: currency.toUpperCase(),
|
|
587
|
+
points_for_currency_amount: options.pointsForCurrencyAmount ?? 2,
|
|
588
|
+
adults: totalAdults,
|
|
589
|
+
children: totalChildren,
|
|
590
|
+
room_name: state.selectedRoom?.name || '',
|
|
591
|
+
room_id: state.selectedRoom?.id ?? '',
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
stripe: {
|
|
596
|
+
amount: total,
|
|
597
|
+
currency: currency.toLowerCase(),
|
|
598
|
+
},
|
|
599
|
+
external_booking,
|
|
600
|
+
internal_booking,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Build payload for payment-intent API (Stripe).
|
|
606
|
+
* Request body: { rate_identifier, key, metadata: { hotel_name, room_type, check_in, check_out, guests, nights, currency, first_name, last_name } }
|
|
607
|
+
* Response: { clientSecret, confirmationToken }
|
|
608
|
+
*
|
|
609
|
+
* @param {Object} state - Widget state: checkIn, checkOut, rooms, occupancies, selectedRoom, selectedRate, guest
|
|
610
|
+
* @param {Object} [options] - { propertyKey: string }
|
|
611
|
+
* @returns {{ rate_identifier: string, key: string, metadata: Object }}
|
|
612
|
+
*/
|
|
613
|
+
function buildPaymentIntentPayload(state, options = {}) {
|
|
614
|
+
const rawKey = options.propertyKey != null && options.propertyKey !== '' ? String(options.propertyKey).trim() : '';
|
|
615
|
+
const propertyKey = rawKey && rawKey.startsWith('book_engine_') ? rawKey : '';
|
|
616
|
+
const checkIn = state.checkIn;
|
|
617
|
+
const checkOut = state.checkOut;
|
|
618
|
+
const nights = checkIn && checkOut
|
|
619
|
+
? Math.max(1, Math.round((checkOut - checkIn) / 86400000))
|
|
620
|
+
: 0;
|
|
621
|
+
const currency = (state.selectedRoom?.currency || 'USD').trim().toUpperCase();
|
|
622
|
+
const occupancies = Array.isArray(state.occupancies) ? state.occupancies : [{ adults: 2, children: 0, childrenAges: [] }];
|
|
623
|
+
const totalAdults = occupancies.reduce((s, o) => s + (o.adults || 0), 0);
|
|
624
|
+
const totalChildren = occupancies.reduce((s, o) => s + (o.children || 0), 0);
|
|
625
|
+
const g = state.guest || {};
|
|
626
|
+
const rateIdentifier = state.selectedRate?.rate_identifier ?? state.selectedRate?.rate_code ?? state.selectedRate?.id ?? '';
|
|
627
|
+
// Same occupancies shape as availability body: adults, children, child_ages?, occupancy_index?
|
|
628
|
+
const occupanciesForPayload = occupancies.map((o, i) => ({
|
|
629
|
+
adults: o.adults ?? 0,
|
|
630
|
+
children: (o.children && o.children > 0) ? o.children : [],
|
|
631
|
+
occupancy_index: i + 1,
|
|
632
|
+
...(Array.isArray(o.childrenAges) && o.childrenAges.length ? { child_ages: o.childrenAges } : {}),
|
|
633
|
+
}));
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
rate_identifier: rateIdentifier,
|
|
637
|
+
key: propertyKey,
|
|
638
|
+
metadata: {
|
|
639
|
+
hotel_name: state.selectedRoom?.name ?? 'Hotel',
|
|
640
|
+
room_type: state.selectedRoom?.name ?? 'Room',
|
|
641
|
+
check_in: toISO(checkIn),
|
|
642
|
+
check_out: toISO(checkOut),
|
|
643
|
+
guests: String(totalAdults + totalChildren),
|
|
644
|
+
nights: String(nights),
|
|
645
|
+
currency,
|
|
646
|
+
first_name: g.firstName ?? '',
|
|
647
|
+
last_name: g.lastName ?? '',
|
|
648
|
+
email: g.email ?? '',
|
|
649
|
+
occupancies: occupanciesForPayload,
|
|
650
|
+
source_transaction: 'booking engine',
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Decrypts property key to get property_id. Call this before fetchBookingEnginePref.
|
|
657
|
+
*
|
|
658
|
+
* @param {Object} options
|
|
659
|
+
* @param {string} options.baseUrl - Base URL for the API (e.g. 'https://thehotelplanet.com')
|
|
660
|
+
* @param {string} options.propertyKey - Hash from env (e.g. VITE_PROPERTY_KEY)
|
|
661
|
+
* @param {Object} [options.headers] - Optional headers (e.g. { 'X-API-Key': '...' })
|
|
662
|
+
* @param {string} [options.locale='en'] - Locale segment in path (default 'en')
|
|
663
|
+
* @returns {Promise<number|null>} property_id or null
|
|
664
|
+
*/
|
|
665
|
+
async function decryptPropertyId(options = {}) {
|
|
666
|
+
const { baseUrl, propertyKey, headers = {}, locale = 'en' } = options;
|
|
667
|
+
if (!propertyKey) return null;
|
|
668
|
+
const base = (baseUrl || '').replace(/\/$/, '');
|
|
669
|
+
const url = `${base}/${locale}/booking_engine/decrypt_property_id`;
|
|
670
|
+
const res = await fetch(url, {
|
|
671
|
+
method: 'POST',
|
|
672
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
673
|
+
body: JSON.stringify({ hash: propertyKey }),
|
|
674
|
+
});
|
|
675
|
+
if (!res.ok) throw new Error(res.statusText || 'Failed to decrypt property id');
|
|
676
|
+
const data = await res.json();
|
|
677
|
+
return data.property_id != null ? Number(data.property_id) : null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Fetches booking engine preferences (colors, links) from the config API.
|
|
682
|
+
*
|
|
683
|
+
* @param {Object} options
|
|
684
|
+
* @param {string} options.baseUrl - Base URL for the API (e.g. 'https://thehotelplanet.com')
|
|
685
|
+
* @param {string|number} options.propertyId - Property ID (from decryptPropertyId)
|
|
686
|
+
* @param {Object} [options.headers] - Optional headers (e.g. { 'X-API-Key': '...' })
|
|
687
|
+
* @param {string} [options.locale='en'] - Locale segment in path (default 'en')
|
|
688
|
+
* @returns {Promise<{ color_header, color_button, color_Text, link_privacy, link_terms }>}
|
|
689
|
+
*/
|
|
690
|
+
async function fetchBookingEnginePref(options = {}) {
|
|
691
|
+
const { baseUrl, propertyId, headers = {}, locale = 'en', debug = false } = options;
|
|
692
|
+
if (!propertyId) {
|
|
693
|
+
return Promise.resolve(null);
|
|
694
|
+
}
|
|
695
|
+
const base = (baseUrl || '').replace(/\/$/, '');
|
|
696
|
+
const url = `${base}/api/core/${locale}/booking_engine/booking_engine_pref?property_id=${encodeURIComponent(propertyId)}`;
|
|
697
|
+
const res = await fetch(url, {
|
|
698
|
+
method: 'GET',
|
|
699
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
700
|
+
});
|
|
701
|
+
if (!res.ok) throw new Error(res.statusText || 'Failed to fetch config');
|
|
702
|
+
|
|
703
|
+
const text = await res.text();
|
|
704
|
+
if (debug) console.log('[fetchBookingEnginePref] raw response:', { status: res.status, contentType: res.headers.get('content-type'), bodyLength: text?.length, bodyPreview: text?.slice(0, 200) });
|
|
705
|
+
if (!text || !text.trim()) return null;
|
|
706
|
+
|
|
707
|
+
let data;
|
|
708
|
+
try {
|
|
709
|
+
data = JSON.parse(text);
|
|
710
|
+
} catch {
|
|
711
|
+
if (debug) console.warn('[fetchBookingEnginePref] invalid JSON');
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Unwrap nested response: { data: {...} } or { result: {...} }
|
|
716
|
+
const pref = data?.data ?? data?.result ?? data;
|
|
717
|
+
const out = pref && typeof pref === 'object' ? pref : null;
|
|
718
|
+
if (debug) console.log('[fetchBookingEnginePref] parsed pref:', out);
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Maps booking_engine_pref API response to widget customColors.
|
|
724
|
+
*
|
|
725
|
+
* @param {Object} pref - Response from fetchBookingEnginePref
|
|
726
|
+
* @returns {Object} customColors for the widget { background, text, primary, primaryText } plus link_privacy, link_terms
|
|
727
|
+
*/
|
|
728
|
+
function mapPrefToCustomColors(pref) {
|
|
729
|
+
if (!pref) return null;
|
|
730
|
+
return {
|
|
731
|
+
background: pref.color_header || '#1a1a1a',
|
|
732
|
+
text: pref.color_Text || '#e0e0e0',
|
|
733
|
+
primary: pref.color_button || '#3b82f6',
|
|
734
|
+
primaryText: '#ffffff',
|
|
735
|
+
link_privacy: pref.link_privacy || '#',
|
|
736
|
+
link_terms: pref.link_terms || '#',
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Support both ESM and CJS
|
|
741
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
742
|
+
module.exports = { createBookingApi, DEFAULT_ROOMS, DEFAULT_RATES, buildCheckoutPayload, buildPaymentIntentPayload, decryptPropertyId, fetchBookingEnginePref, mapPrefToCustomColors, getCurrencySymbol, formatPrice };
|
|
743
|
+
}
|
|
744
|
+
if (typeof window !== 'undefined') {
|
|
745
|
+
window.createBookingApi = createBookingApi;
|
|
746
|
+
window.buildCheckoutPayload = buildCheckoutPayload;
|
|
747
|
+
window.buildPaymentIntentPayload = buildPaymentIntentPayload;
|
|
748
|
+
window.decryptPropertyId = decryptPropertyId;
|
|
749
|
+
window.fetchBookingEnginePref = fetchBookingEnginePref;
|
|
750
|
+
window.mapPrefToCustomColors = mapPrefToCustomColors;
|
|
751
|
+
window.getCurrencySymbol = getCurrencySymbol;
|
|
752
|
+
window.formatPrice = formatPrice;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export { createBookingApi, DEFAULT_ROOMS, DEFAULT_RATES, buildCheckoutPayload, buildPaymentIntentPayload, decryptPropertyId, fetchBookingEnginePref, mapPrefToCustomColors, getCurrencySymbol, formatPrice };
|