@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.
@@ -0,0 +1,1192 @@
1
+ import React, { useState, useEffect, useRef, useMemo } from 'react';
2
+ import { loadStripe } from '@stripe/stripe-js';
3
+ import { Calendar, Users, User, Check, MapPin, Phone, Square, CreditCard, Lock, Star, X, ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import '../core/styles.css';
5
+ import { createBookingApi, formatPrice, buildCheckoutPayload, buildPaymentIntentPayload } from '../core/booking-api.js';
6
+ import { STRIPE_PUBLISHABLE_KEY, API_BASE_URL } from '../core/stripe-config.js';
7
+
8
+ const BASE_STEPS = [
9
+ { key: 'dates', label: 'Dates & Guests', num: '01' },
10
+ { key: 'rooms', label: 'Room', num: '02' },
11
+ { key: 'rates', label: 'Rate', num: '03' },
12
+ { key: 'summary', label: 'Summary', num: '04' },
13
+ ];
14
+ function buildSteps(hasStripe) {
15
+ return hasStripe ? [...BASE_STEPS, { key: 'payment', label: 'Payment', num: '05' }] : BASE_STEPS;
16
+ }
17
+
18
+ // Empty container for Stripe Payment Element so React never re-renders it and clears Stripe's DOM
19
+ const PaymentElementContainer = React.memo(function PaymentElementContainer() {
20
+ return <div id="booking-widget-payment-element" className="booking-widget-payment-element" />;
21
+ });
22
+
23
+ const BookingWidget = ({
24
+ isOpen,
25
+ onClose,
26
+ onComplete,
27
+ onOpen,
28
+ colors,
29
+ apiBaseUrl = '',
30
+ bookingApi: bookingApiProp = null,
31
+ apiSecret = '',
32
+ propertyId = '',
33
+ propertyKey = '',
34
+ availabilityBaseUrl = '',
35
+ propertyBaseUrl = '',
36
+ s3BaseUrl = '',
37
+ /** If set, called with checkout payload (Stripe + external_booking + internal_booking) instead of createBooking. Return { confirmationCode } or resolve to go to confirmation. */
38
+ onBeforeConfirm = null,
39
+ /** Stripe: publishable key. Defaults to package .env (STRIPE_PUBLICED_KEY) at build time; installers don't set this. */
40
+ stripePublishableKey: stripePublishableKeyProp = STRIPE_PUBLISHABLE_KEY,
41
+ /** Stripe: optional override. If not set, package uses .env (VITE_API_BASE_URL or API_BASE_URL) + '/proxy/create-payment-intent'. */
42
+ createPaymentIntent: createPaymentIntentProp = null,
43
+ /** Stripe: deprecated (booking completion is fetched from confirmation endpoint instead). */
44
+ onBookingComplete: onBookingCompleteProp = null,
45
+ }) => {
46
+ const stripePublishableKey = stripePublishableKeyProp || STRIPE_PUBLISHABLE_KEY;
47
+ const env = typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {};
48
+ const apiBase = (env.VITE_API_BASE_URL || API_BASE_URL || '').replace(/\/$/, '');
49
+ const effectivePaymentIntentUrl = (apiBase ? apiBase + '/proxy/create-payment-intent' : '').trim();
50
+ const createPaymentIntent = useMemo(() => {
51
+ if (typeof createPaymentIntentProp === 'function') return createPaymentIntentProp;
52
+ if (!effectivePaymentIntentUrl) return null;
53
+ return async (payload) => {
54
+ const headers = { 'Content-Type': 'application/json' };
55
+ if (effectivePaymentIntentUrl.includes('ngrok')) headers['ngrok-skip-browser-warning'] = 'true';
56
+ const res = await fetch(effectivePaymentIntentUrl, { method: 'POST', headers, body: JSON.stringify(payload) });
57
+ if (!res.ok) throw new Error(await res.text());
58
+ const data = await res.json();
59
+ const clientSecret = data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret;
60
+ const confirmationToken = data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token;
61
+ return { clientSecret, confirmationToken };
62
+ };
63
+ }, [createPaymentIntentProp, effectivePaymentIntentUrl]);
64
+ const onBookingComplete = useMemo(() => onBookingCompleteProp ?? null, [onBookingCompleteProp]);
65
+ const hasStripe = Boolean(stripePublishableKey && typeof createPaymentIntent === 'function');
66
+ const STEPS = useMemo(() => buildSteps(hasStripe), [hasStripe]);
67
+ const [state, setState] = useState({
68
+ step: 'dates',
69
+ checkIn: null,
70
+ checkOut: null,
71
+ rooms: 1,
72
+ occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
73
+ selectedRoom: null,
74
+ selectedRate: null,
75
+ guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' },
76
+ });
77
+
78
+ const [roomsList, setRoomsList] = useState([]);
79
+ const [ratesList, setRatesList] = useState([]);
80
+ const [loadingRooms, setLoadingRooms] = useState(false);
81
+ const [loadingRates, setLoadingRates] = useState(false);
82
+ const [apiError, setApiError] = useState(null);
83
+ const [confirmationCode, setConfirmationCode] = useState(null);
84
+ const [confirmationToken, setConfirmationToken] = useState(null);
85
+ const [confirmationStatus, setConfirmationStatus] = useState(null);
86
+ const [confirmationDetails, setConfirmationDetails] = useState(null);
87
+
88
+ const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth());
89
+ const [calendarYear, setCalendarYear] = useState(new Date().getFullYear());
90
+ const [pickState, setPickState] = useState(0);
91
+ const [calendarOpen, setCalendarOpen] = useState(false);
92
+ const [paymentElementReady, setPaymentElementReady] = useState(false);
93
+ const [checkoutShowPaymentForm, setCheckoutShowPaymentForm] = useState(false);
94
+ const [isClosing, setIsClosing] = useState(false);
95
+ const [isReadyForOpen, setIsReadyForOpen] = useState(false);
96
+ const widgetRef = useRef(null);
97
+ const stripeRef = useRef(null);
98
+ const elementsRef = useRef(null);
99
+ const paymentIntentConfirmationTokenRef = useRef(null);
100
+
101
+ // Use package .env when props not passed (e.g. examples with envDir pointing at root)
102
+ const effectiveApiBaseUrl = apiBaseUrl || env.VITE_API_URL || '';
103
+ const effectiveConfirmationBaseUrl = (env.VITE_API_BASE_URL || API_BASE_URL || 'https://ai.thehotelplanet.com').replace(/\/$/, '');
104
+ const apiBaseForAvailability = (env.VITE_API_BASE_URL || API_BASE_URL || '').replace(/\/$/, '');
105
+ const effectiveAvailabilityBaseUrl = availabilityBaseUrl !== '' ? availabilityBaseUrl : apiBaseForAvailability;
106
+ const effectivePropertyBaseUrl = propertyBaseUrl || env.VITE_PROPERTY_BASE_URL || '';
107
+ const effectiveS3BaseUrl = s3BaseUrl || env.VITE_AWS_S3_PATH || '';
108
+
109
+ const bookingApiRef = useMemo(() => {
110
+ if (bookingApiProp && typeof bookingApiProp.fetchRooms === 'function') return bookingApiProp;
111
+ if ((effectiveApiBaseUrl || propertyKey) && typeof createBookingApi === 'function') {
112
+ return createBookingApi({
113
+ baseUrl: effectiveApiBaseUrl || effectiveAvailabilityBaseUrl || '',
114
+ availabilityBaseUrl: effectiveAvailabilityBaseUrl === '' ? '' : (effectiveAvailabilityBaseUrl || undefined),
115
+ propertyBaseUrl: effectivePropertyBaseUrl === '' ? '' : (effectivePropertyBaseUrl || undefined),
116
+ s3BaseUrl: effectiveS3BaseUrl || undefined,
117
+ propertyId: propertyId || undefined,
118
+ propertyKey: propertyKey || undefined,
119
+ headers: apiSecret ? { 'X-API-Key': apiSecret } : undefined,
120
+ });
121
+ }
122
+ return null;
123
+ }, [bookingApiProp, effectiveApiBaseUrl, effectiveAvailabilityBaseUrl, effectivePropertyBaseUrl, effectiveS3BaseUrl, apiSecret, propertyId, propertyKey]);
124
+
125
+ useEffect(() => {
126
+ if (isOpen && onOpen) onOpen();
127
+ }, [isOpen, onOpen]);
128
+
129
+ // Delay adding 'active' by one frame so the browser can paint the initial state and animate open
130
+ useEffect(() => {
131
+ if (!isOpen || isClosing) {
132
+ setIsReadyForOpen(false);
133
+ return;
134
+ }
135
+ const id = requestAnimationFrame(() => {
136
+ requestAnimationFrame(() => setIsReadyForOpen(true));
137
+ });
138
+ return () => cancelAnimationFrame(id);
139
+ }, [isOpen, isClosing]);
140
+
141
+ // Stripe: mount Payment Element only on payment step after user proceeded from summary
142
+ useEffect(() => {
143
+ if (state.step !== 'payment' || !checkoutShowPaymentForm || !stripePublishableKey || typeof createPaymentIntent !== 'function') {
144
+ if (state.step !== 'payment') setCheckoutShowPaymentForm(false);
145
+ setPaymentElementReady(false);
146
+ return;
147
+ }
148
+ const paymentIntentPayload = buildPaymentIntentPayload(state, { propertyKey: propertyKey || undefined });
149
+ let mounted = true;
150
+ setPaymentElementReady(false);
151
+ setApiError(null);
152
+ (async () => {
153
+ try {
154
+ const result = await createPaymentIntent(paymentIntentPayload);
155
+ const clientSecret = result?.clientSecret ?? result?.client_secret ?? result?.data?.clientSecret ?? result?.data?.client_secret ?? result?.paymentIntent?.client_secret;
156
+ if (mounted) paymentIntentConfirmationTokenRef.current = result?.confirmationToken ?? result?.confirmation_token ?? result?.data?.confirmationToken ?? result?.data?.confirmation_token;
157
+ if (!mounted || !clientSecret) return;
158
+ const stripe = await loadStripe(stripePublishableKey);
159
+ if (!mounted || !stripe) return;
160
+ stripeRef.current = stripe;
161
+ const elements = stripe.elements({ clientSecret, appearance: { theme: 'flat', variables: { borderRadius: '8px' } } });
162
+ elementsRef.current = elements;
163
+ const paymentElement = elements.create('payment');
164
+ // Defer mount so the checkout step DOM is committed and the container exists
165
+ await new Promise((r) => requestAnimationFrame(r));
166
+ if (!mounted) return;
167
+ const container = document.getElementById('booking-widget-payment-element');
168
+ if (!container) return;
169
+ container.innerHTML = '';
170
+ paymentElement.mount(container);
171
+ if (mounted) setPaymentElementReady(true);
172
+ } catch (err) {
173
+ if (mounted) setApiError(err?.message || err || 'Payment setup failed');
174
+ }
175
+ })();
176
+ return () => {
177
+ mounted = false;
178
+ if (elementsRef.current) {
179
+ const container = document.getElementById('booking-widget-payment-element');
180
+ if (container) container.innerHTML = '';
181
+ elementsRef.current = null;
182
+ }
183
+ stripeRef.current = null;
184
+ };
185
+ }, [state.step, checkoutShowPaymentForm, stripePublishableKey, createPaymentIntent, onBookingComplete, state.checkIn, state.checkOut, state.rooms, state.occupancies, state.selectedRoom?.id, state.selectedRate?.id, state.guest, propertyId, propertyKey]);
186
+
187
+ // After Stripe confirms payment, poll confirmation endpoint every 2s until status === 'confirmed'
188
+ useEffect(() => {
189
+ if (state.step !== 'confirmation' || !confirmationToken) return;
190
+ let cancelled = false;
191
+ let timer = null;
192
+ const pollOnce = async () => {
193
+ if (cancelled) return;
194
+ try {
195
+ const url = `${effectiveConfirmationBaseUrl}/proxy/confirmation/${encodeURIComponent(String(confirmationToken).trim())}`;
196
+ const res = await fetch(url, { method: 'POST' });
197
+ if (!res.ok) throw new Error(await res.text());
198
+ const data = await res.json();
199
+ const status = data?.status != null ? String(data.status) : '';
200
+ if (!cancelled) setConfirmationStatus(status || 'pending');
201
+ if (status === 'confirmed') {
202
+ if (!cancelled) setConfirmationDetails(data);
203
+ return;
204
+ }
205
+ } catch (err) {
206
+ if (!cancelled) setApiError(err?.message || err || 'Failed to fetch confirmation');
207
+ }
208
+ timer = setTimeout(pollOnce, 2000);
209
+ };
210
+ pollOnce();
211
+ return () => {
212
+ cancelled = true;
213
+ if (timer) clearTimeout(timer);
214
+ };
215
+ }, [state.step, confirmationToken, effectiveConfirmationBaseUrl]);
216
+
217
+ // Apply custom colors
218
+ useEffect(() => {
219
+ if (widgetRef.current && colors) {
220
+ const style = widgetRef.current.style;
221
+ if (colors.background) {
222
+ style.setProperty('--bg', colors.background);
223
+ style.setProperty('--card', colors.background);
224
+ }
225
+ if (colors.text) {
226
+ style.setProperty('--fg', colors.text);
227
+ style.setProperty('--card-fg', colors.text);
228
+ }
229
+ if (colors.primary) {
230
+ style.setProperty('--primary', colors.primary);
231
+ }
232
+ if (colors.primaryText) {
233
+ style.setProperty('--primary-fg', colors.primaryText);
234
+ }
235
+ }
236
+ }, [colors, isOpen]);
237
+
238
+ const getNights = () => {
239
+ if (!state.checkIn || !state.checkOut) return 0;
240
+ return Math.max(1, Math.round((state.checkOut - state.checkIn) / 86400000));
241
+ };
242
+
243
+ /** API basePrice is total for the full stay (per room). Total = basePrice * priceModifier * rooms. */
244
+ const getTotalPrice = () => {
245
+ if (!state.selectedRoom || !state.selectedRate) return 0;
246
+ const nights = getNights();
247
+ const rooms = state.rooms;
248
+ const roomTotal = Math.round(state.selectedRoom.basePrice * state.selectedRate.priceModifier * rooms);
249
+ const fees = state.selectedRate.fees ?? [];
250
+ let add = 0;
251
+ fees.forEach(f => { if (!f.included) add += f.perNight ? f.amount * nights * rooms : f.amount; });
252
+ const vat = state.selectedRate.vat;
253
+ if (vat && !vat.included) add += vat.value || 0;
254
+ return Math.round(roomTotal + add);
255
+ };
256
+
257
+ const fmt = (date) => {
258
+ if (!date) return '';
259
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
260
+ };
261
+
262
+ const fmtLong = (date) => {
263
+ if (!date) return '';
264
+ return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
265
+ };
266
+
267
+ const formatRoomSize = (size) => {
268
+ if (size == null || typeof size !== 'string') return size ?? '';
269
+ return size.replace(/\bsquare_?meter(s)?\b/gi, 'm²').trim();
270
+ };
271
+
272
+ const stepIndex = (key) => STEPS.findIndex(s => s.key === key);
273
+
274
+ const goToStep = (step) => {
275
+ setApiError(null);
276
+ if (step !== 'summary' && step !== 'payment') setCheckoutShowPaymentForm(false);
277
+ if (step === 'payment') setCheckoutShowPaymentForm(true);
278
+ setState(prev => ({ ...prev, step }));
279
+ const api = bookingApiRef;
280
+ if (step === 'rooms' && api) {
281
+ setLoadingRooms(true);
282
+ const occupancies = (state.occupancies || []).slice(0, state.rooms).map((occ, i) => ({
283
+ adults: occ.adults ?? 1,
284
+ children: (occ.childrenAges || []).slice(0, occ.children || 0).map(a => Number(a) || 0),
285
+ occupancy_index: i + 1,
286
+ }));
287
+ const params = { checkIn: state.checkIn, checkOut: state.checkOut, rooms: state.rooms, occupancies };
288
+ api.fetchRooms(params).then((rooms) => {
289
+ setRoomsList(Array.isArray(rooms) ? rooms : []);
290
+ setLoadingRooms(false);
291
+ }).catch((err) => {
292
+ setApiError(err.message || 'Failed to load rooms');
293
+ setLoadingRooms(false);
294
+ });
295
+ return;
296
+ }
297
+ if (step === 'rates' && state.selectedRoom) {
298
+ setLoadingRates(false);
299
+ const rates = Array.isArray(state.selectedRoom.rates) && state.selectedRoom.rates.length
300
+ ? state.selectedRoom.rates
301
+ : [];
302
+ setRatesList(rates);
303
+ return;
304
+ }
305
+ };
306
+
307
+ const changeCounter = (field, delta, roomIndex = 0) => {
308
+ const limits = { adults: [1,8], children: [0,6], rooms: [1,5] };
309
+ setState(prev => {
310
+ const occ = [...(prev.occupancies || [{ adults: 2, children: 0, childrenAges: [] }])];
311
+ if (field === 'rooms') {
312
+ const next = Math.max(limits.rooms[0], Math.min(limits.rooms[1], prev.rooms + delta));
313
+ while (occ.length < next) occ.push({ adults: 1, children: 0, childrenAges: [] });
314
+ occ.length = next;
315
+ return { ...prev, rooms: next, occupancies: occ };
316
+ }
317
+ if (roomIndex < 0 || roomIndex >= occ.length) return prev;
318
+ const next = Math.max(limits[field][0], Math.min(limits[field][1], (occ[roomIndex][field] ?? (field === 'adults' ? 1 : 0)) + delta));
319
+ occ[roomIndex] = { ...occ[roomIndex], [field]: next };
320
+ if (field === 'children') {
321
+ const ages = occ[roomIndex].childrenAges || [];
322
+ const newAges = ages.slice(0, next);
323
+ while (newAges.length < next) newAges.push(0);
324
+ occ[roomIndex].childrenAges = newAges;
325
+ }
326
+ return { ...prev, occupancies: occ };
327
+ });
328
+ };
329
+
330
+ const updateChildAge = (roomIndex, childIndex, value) => {
331
+ const age = Math.max(0, Math.min(17, Number(value) || 0));
332
+ setState(prev => {
333
+ const occ = [...(prev.occupancies || [])];
334
+ if (roomIndex < 0 || roomIndex >= occ.length) return prev;
335
+ const ages = [...(occ[roomIndex].childrenAges || [])];
336
+ if (childIndex >= 0 && childIndex < ages.length) ages[childIndex] = age;
337
+ occ[roomIndex] = { ...occ[roomIndex], childrenAges: ages };
338
+ return { ...prev, occupancies: occ };
339
+ });
340
+ };
341
+
342
+ const removeRoom = (roomIndex) => {
343
+ if (state.rooms <= 1) return;
344
+ setState(prev => {
345
+ const occ = [...(prev.occupancies || [])];
346
+ if (roomIndex < 0 || roomIndex >= occ.length) return prev;
347
+ occ.splice(roomIndex, 1);
348
+ return { ...prev, occupancies: occ, rooms: occ.length };
349
+ });
350
+ };
351
+
352
+ const getTotalGuests = () => {
353
+ const occ = state.occupancies || [];
354
+ return {
355
+ adults: occ.reduce((s, o) => s + (o.adults || 0), 0),
356
+ children: occ.reduce((s, o) => s + (o.children || 0), 0),
357
+ };
358
+ };
359
+
360
+ const pickDate = (y, m, d) => {
361
+ const date = new Date(y, m, d);
362
+ if (pickState === 0 || (state.checkIn && date <= state.checkIn)) {
363
+ setState(prev => ({ ...prev, checkIn: date, checkOut: null }));
364
+ setPickState(1);
365
+ } else {
366
+ setState(prev => ({ ...prev, checkOut: date }));
367
+ setPickState(0);
368
+ setCalendarOpen(false);
369
+ }
370
+ if (pickState === 0) {
371
+ setCalendarMonth(new Date().getMonth());
372
+ setCalendarYear(new Date().getFullYear());
373
+ setPickState(1);
374
+ }
375
+ };
376
+
377
+ const calNav = (dir) => {
378
+ setCalendarMonth(prev => {
379
+ const newMonth = prev + dir;
380
+ if (newMonth > 11) {
381
+ setCalendarYear(prevYear => prevYear + 1);
382
+ return 0;
383
+ }
384
+ if (newMonth < 0) {
385
+ setCalendarYear(prevYear => prevYear - 1);
386
+ return 11;
387
+ }
388
+ return newMonth;
389
+ });
390
+ };
391
+
392
+ const monthName = (m) => ['January','February','March','April','May','June','July','August','September','October','November','December'][m];
393
+
394
+ const buildMonth = (year, month) => {
395
+ const today = new Date(); today.setHours(0,0,0,0);
396
+ const first = new Date(year, month, 1);
397
+ const days = new Date(year, month + 1, 0).getDate();
398
+ const startDay = first.getDay();
399
+ const names = ['Su','Mo','Tu','We','Th','Fr','Sa'];
400
+ let daysArray = [];
401
+ for (let i = 0; i < startDay; i++) daysArray.push(null);
402
+ for (let d = 1; d <= days; d++) {
403
+ const date = new Date(year, month, d);
404
+ const disabled = date < today;
405
+ let cls = 'cal-day';
406
+ if (disabled) cls += ' disabled';
407
+ if (date.getTime() === today.getTime()) cls += ' today';
408
+ if (state.checkIn && date.getTime() === state.checkIn.getTime()) cls += ' selected';
409
+ if (state.checkOut && date.getTime() === state.checkOut.getTime()) cls += ' selected';
410
+ if (state.checkIn && state.checkOut && date > state.checkIn && date < state.checkOut) cls += ' in-range';
411
+ daysArray.push({ date, disabled, cls, day: d });
412
+ }
413
+ return { daysArray, names };
414
+ };
415
+
416
+ const selectRoom = (id) => {
417
+ setState(prev => ({ ...prev, selectedRoom: roomsList.find(r => r.id === id) }));
418
+ };
419
+
420
+ const selectRate = (id) => {
421
+ setState(prev => ({ ...prev, selectedRate: ratesList.find(r => r.id === id) }));
422
+ };
423
+
424
+ const updateGuest = (field, value) => {
425
+ setState(prev => ({
426
+ ...prev,
427
+ guest: { ...prev.guest, [field]: value }
428
+ }));
429
+ };
430
+
431
+ const confirmReservation = () => {
432
+ const canSubmit = state.guest.firstName && state.guest.lastName && state.guest.email;
433
+ if (!canSubmit) return;
434
+ const payload = buildCheckoutPayload(state, { propertyId: propertyId || undefined, propertyKey: propertyKey || undefined });
435
+ // Summary step with Stripe: go to payment step (payment form loads there)
436
+ if (state.step === 'summary' && stripePublishableKey && typeof createPaymentIntent === 'function') {
437
+ setApiError(null);
438
+ goToStep('payment');
439
+ return;
440
+ }
441
+
442
+ // Payment step: confirm payment (Payment Element already loaded)
443
+ if (state.step === 'payment' && stripePublishableKey && typeof createPaymentIntent === 'function' && elementsRef.current && stripeRef.current) {
444
+ setApiError(null);
445
+ stripeRef.current.confirmPayment({
446
+ elements: elementsRef.current,
447
+ confirmParams: { return_url: typeof window !== 'undefined' ? window.location.origin + (window.location.pathname || '') : '' },
448
+ redirect: 'if_required',
449
+ }).then(({ error, paymentIntent }) => {
450
+ if (error) {
451
+ setApiError(error?.message || 'Payment failed');
452
+ return;
453
+ }
454
+ if (paymentIntent?.status === 'succeeded' || paymentIntent?.status === 'requires_capture') {
455
+ if (!paymentIntentConfirmationTokenRef.current) {
456
+ setApiError('Missing confirmation token from payment intent');
457
+ return;
458
+ }
459
+ setApiError(null);
460
+ setConfirmationDetails(null);
461
+ setConfirmationStatus('pending');
462
+ setConfirmationToken(paymentIntentConfirmationTokenRef.current);
463
+ setState(prev => ({ ...prev, step: 'confirmation' }));
464
+ }
465
+ }).catch((err) => setApiError(err?.message || err || 'Payment failed'));
466
+ return;
467
+ }
468
+
469
+ if (typeof onBeforeConfirm === 'function') {
470
+ setApiError(null);
471
+ Promise.resolve(onBeforeConfirm(payload))
472
+ .then((res) => {
473
+ const code = res && (res.confirmationCode ?? res.confirmation_code);
474
+ setConfirmationCode(code || ('LX' + Date.now().toString(36).toUpperCase().slice(-6)));
475
+ setState(prev => ({ ...prev, step: 'confirmation' }));
476
+ })
477
+ .catch((err) => {
478
+ setApiError(err?.message || err || 'Booking failed');
479
+ });
480
+ return;
481
+ }
482
+ // No Stripe flow and no onBeforeConfirm: only log payload
483
+ return;
484
+ const api = bookingApiRef;
485
+ if (api) {
486
+ setApiError(null);
487
+ api.createBooking(state).then((res) => {
488
+ setConfirmationCode(res.confirmationCode || ('LX' + Date.now().toString(36).toUpperCase().slice(-6)));
489
+ setState(prev => ({ ...prev, step: 'confirmation' }));
490
+ }).catch((err) => {
491
+ setApiError(err.message || 'Booking failed');
492
+ });
493
+ return;
494
+ }
495
+ setConfirmationCode('LX' + Date.now().toString(36).toUpperCase().slice(-6));
496
+ setState(prev => ({ ...prev, step: 'confirmation' }));
497
+ };
498
+
499
+ const resetBooking = () => {
500
+ setCheckoutShowPaymentForm(false);
501
+ setState({
502
+ step: 'dates',
503
+ checkIn: null,
504
+ checkOut: null,
505
+ rooms: 1,
506
+ occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
507
+ selectedRoom: null,
508
+ selectedRate: null,
509
+ guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' }
510
+ });
511
+ setConfirmationCode(null);
512
+ setConfirmationToken(null);
513
+ setConfirmationStatus(null);
514
+ setConfirmationDetails(null);
515
+ setApiError(null);
516
+ paymentIntentConfirmationTokenRef.current = null;
517
+ if (!bookingApiRef) {
518
+ setRoomsList([]);
519
+ setRatesList([]);
520
+ }
521
+ if (onComplete) onComplete(state);
522
+ };
523
+
524
+ const resetStateForClose = () => {
525
+ setCheckoutShowPaymentForm(false);
526
+ setState({
527
+ step: 'dates',
528
+ checkIn: null,
529
+ checkOut: null,
530
+ rooms: 1,
531
+ occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
532
+ selectedRoom: null,
533
+ selectedRate: null,
534
+ guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' }
535
+ });
536
+ setConfirmationCode(null);
537
+ setConfirmationToken(null);
538
+ setConfirmationStatus(null);
539
+ setConfirmationDetails(null);
540
+ setApiError(null);
541
+ paymentIntentConfirmationTokenRef.current = null;
542
+ setRoomsList([]);
543
+ setRatesList([]);
544
+ if (onClose) onClose();
545
+ };
546
+
547
+ const requestClose = () => {
548
+ if (isClosing) return;
549
+ setIsClosing(true);
550
+ };
551
+
552
+ const handleTransitionEnd = (e) => {
553
+ if (e.target !== widgetRef.current || e.propertyName !== 'transform') return;
554
+ if (!isClosing) return;
555
+ resetStateForClose();
556
+ setIsClosing(false);
557
+ };
558
+
559
+ const renderStepIndicator = () => {
560
+ if (state.step === 'confirmation') return null;
561
+ const ci = stepIndex(state.step);
562
+ return (
563
+ <div className="booking-widget-step-indicator">
564
+ {STEPS.map((s, i) => {
565
+ const cls = i === ci ? 'active' : i < ci ? 'past' : 'future';
566
+ return (
567
+ <React.Fragment key={s.key}>
568
+ <div className="step-item">
569
+ <span className={`step-circle ${cls}`} onClick={cls === 'past' ? () => goToStep(s.key) : undefined}>
570
+ {i < ci ? <Check size={12} /> : s.num}
571
+ </span>
572
+ <span className={`step-label ${cls}`} onClick={cls === 'past' ? () => goToStep(s.key) : undefined}>
573
+ {s.label}
574
+ </span>
575
+ </div>
576
+ {i < STEPS.length - 1 && (
577
+ <span className="step-line">
578
+ <span className={`step-line-fill ${i < ci ? 'filled' : ''}`}></span>
579
+ </span>
580
+ )}
581
+ </React.Fragment>
582
+ );
583
+ })}
584
+ </div>
585
+ );
586
+ };
587
+
588
+ const renderDatesStep = () => {
589
+ const m1 = calendarMonth, y1 = calendarYear;
590
+ const m2 = m1 === 11 ? 0 : m1 + 1, y2 = m1 === 11 ? y1 + 1 : y1;
591
+ const month1 = buildMonth(y1, m1);
592
+ const month2 = buildMonth(y2, m2);
593
+
594
+ return (
595
+ <>
596
+ <h2 className="step-title">Plan Your Stay</h2>
597
+ <p className="step-subtitle">Select your dates and guests to begin</p>
598
+ <div style={{maxWidth: '32em', margin: '0 auto'}}>
599
+ <label className="form-label">Dates</label>
600
+ <div className="date-wrapper">
601
+ <div className="date-trigger" onClick={() => setCalendarOpen(!calendarOpen)}>
602
+ <Calendar size={18} style={{marginRight: '0.5em'}} />
603
+ <span className={!state.checkIn ? 'placeholder' : ''}>
604
+ {state.checkIn ? fmt(state.checkIn) : 'Check-in'}
605
+ </span>
606
+ &nbsp;→&nbsp;
607
+ <span className={!state.checkOut ? 'placeholder' : ''}>
608
+ {state.checkOut ? fmt(state.checkOut) : 'Check-out'}
609
+ </span>
610
+ </div>
611
+ {calendarOpen && (
612
+ <div className="calendar-popup open">
613
+ <div className="cal-header">
614
+ <button onClick={() => calNav(-1)}>‹</button>
615
+ <span className="cal-title">{monthName(m1)} {y1}</span>
616
+ <span className="cal-title">{monthName(m2)} {y2}</span>
617
+ <button onClick={() => calNav(1)}>›</button>
618
+ </div>
619
+ <div className="cal-months">
620
+ <div className="cal-grid">
621
+ {month1.names.map(n => <div key={n} className="cal-day-name">{n}</div>)}
622
+ {month1.daysArray.map((day, idx) =>
623
+ day ? (
624
+ <button key={idx} className={day.cls} disabled={day.disabled} onClick={() => !day.disabled && pickDate(y1, m1, day.day)}>
625
+ {day.day}
626
+ </button>
627
+ ) : <div key={idx} className="cal-day empty"></div>
628
+ )}
629
+ </div>
630
+ <div className="cal-grid">
631
+ {month2.names.map(n => <div key={n} className="cal-day-name">{n}</div>)}
632
+ {month2.daysArray.map((day, idx) =>
633
+ day ? (
634
+ <button key={idx} className={day.cls} disabled={day.disabled} onClick={() => !day.disabled && pickDate(y2, m2, day.day)}>
635
+ {day.day}
636
+ </button>
637
+ ) : <div key={idx} className="cal-day empty"></div>
638
+ )}
639
+ </div>
640
+ </div>
641
+ </div>
642
+ )}
643
+ </div>
644
+ <label className="form-label" style={{marginTop: '1.5em', display: 'flex', alignItems: 'center', gap: '0.5em'}}>
645
+ <Users size={18} /> Guests & Rooms
646
+ </label>
647
+ <div className="guests-rooms-section">
648
+ {(state.occupancies || []).slice(0, state.rooms).map((occ, roomIdx) => (
649
+ <div key={roomIdx} className="room-card">
650
+ <div className="room-card-header">
651
+ <span className="room-card-title">Room {roomIdx + 1}</span>
652
+ {state.rooms > 1 && (
653
+ <button type="button" className="remove-room-btn" onClick={() => removeRoom(roomIdx)} aria-label="Remove room">Remove</button>
654
+ )}
655
+ </div>
656
+ <div className="counter-row">
657
+ <span className="counter-label">Adults</span>
658
+ <div className="counter-controls">
659
+ <button className="counter-btn" disabled={(occ.adults ?? 1) <= 1} onClick={() => changeCounter('adults', -1, roomIdx)}>−</button>
660
+ <span className="counter-val">{occ.adults ?? 1}</span>
661
+ <button className="counter-btn" disabled={(occ.adults ?? 1) >= 8} onClick={() => changeCounter('adults', 1, roomIdx)}>+</button>
662
+ </div>
663
+ </div>
664
+ <div className="counter-row">
665
+ <span className="counter-label">Children</span>
666
+ <div className="counter-controls">
667
+ <button className="counter-btn" disabled={(occ.children ?? 0) <= 0} onClick={() => changeCounter('children', -1, roomIdx)}>−</button>
668
+ <span className="counter-val">{occ.children ?? 0}</span>
669
+ <button className="counter-btn" disabled={(occ.children ?? 0) >= 6} onClick={() => changeCounter('children', 1, roomIdx)}>+</button>
670
+ </div>
671
+ </div>
672
+ {(occ.children ?? 0) > 0 && (
673
+ <div className="child-ages-section" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5em 1em' }}>
674
+ {Array.from({ length: occ.children ?? 0 }, (_, i) => (
675
+ <div key={i} className="child-age-row" style={{ flex: '0 1 calc(50% - 0.5em)', minWidth: 0, marginTop: '0.5em' }}>
676
+ <label className="form-label" style={{ fontSize: '0.75em', color: 'var(--muted)' }}>Child {i + 1} age</label>
677
+ <select
678
+ className="form-input"
679
+ style={{ width: '100%', marginTop: '0.25em' }}
680
+ value={(occ.childrenAges || [])[i] ?? 0}
681
+ onChange={(e) => updateChildAge(roomIdx, i, parseInt(e.target.value, 10))}
682
+ >
683
+ {Array.from({ length: 18 }, (_, a) => (
684
+ <option key={a} value={a}>{a} year{a !== 1 ? 's' : ''}</option>
685
+ ))}
686
+ </select>
687
+ </div>
688
+ ))}
689
+ </div>
690
+ )}
691
+ </div>
692
+ ))}
693
+ <button type="button" className="add-room-btn" disabled={state.rooms >= 5} onClick={() => changeCounter('rooms', 1)}>
694
+ + Add room
695
+ </button>
696
+ </div>
697
+ <button className="btn-primary" disabled={!state.checkIn || !state.checkOut} onClick={() => goToStep('rooms')}>
698
+ Select Room
699
+ </button>
700
+ </div>
701
+ </>
702
+ );
703
+ };
704
+
705
+ const roomGridRef = useRef(null);
706
+
707
+ const scrollRooms = (direction) => {
708
+ if (roomGridRef.current) {
709
+ const scrollAmount = 300;
710
+ roomGridRef.current.scrollBy({
711
+ left: direction * scrollAmount,
712
+ behavior: 'smooth'
713
+ });
714
+ }
715
+ };
716
+
717
+ const renderRoomSkeleton = () => (
718
+ <div className="room-skeleton">
719
+ <div className="room-skeleton-img" />
720
+ <div className="room-skeleton-body">
721
+ <div className="room-skeleton-line title" />
722
+ <div className="room-skeleton-line price" />
723
+ <div className="room-skeleton-line desc" />
724
+ <div className="room-skeleton-line desc" />
725
+ <div className="room-skeleton-line desc" />
726
+ <div className="room-skeleton-meta">
727
+ <div className="room-skeleton-line meta" />
728
+ <div className="room-skeleton-line meta" />
729
+ </div>
730
+ <div className="room-skeleton-tags">
731
+ <div className="room-skeleton-tag" />
732
+ <div className="room-skeleton-tag" />
733
+ <div className="room-skeleton-tag" />
734
+ </div>
735
+ </div>
736
+ </div>
737
+ );
738
+
739
+ const renderRoomsStep = () => {
740
+ if (loadingRooms) {
741
+ return (
742
+ <>
743
+ <h2 className="step-title">Choose Your Room</h2>
744
+ <p className="step-subtitle">Each space is crafted for an unforgettable experience</p>
745
+ <div className="room-grid-wrapper">
746
+ <div className="room-grid">
747
+ {[1, 2, 3, 4].map((i) => <React.Fragment key={i}>{renderRoomSkeleton()}</React.Fragment>)}
748
+ </div>
749
+ </div>
750
+ </>
751
+ );
752
+ }
753
+ if (apiError) {
754
+ return (
755
+ <>
756
+ <h2 className="step-title">Choose Your Room</h2>
757
+ <p className="step-subtitle" style={{ color: 'var(--destructive, #ef4444)' }}>{apiError}</p>
758
+ <button className="btn-secondary" onClick={() => goToStep('rooms')}>Try again</button>
759
+ </>
760
+ );
761
+ }
762
+ if (roomsList.length === 0) {
763
+ return (
764
+ <>
765
+ <h2 className="step-title">Choose Your Room</h2>
766
+ <p className="step-subtitle">No rooms available for the selected dates.</p>
767
+ <div className="rooms-empty-state">
768
+ <p className="rooms-empty-message">Try different dates or check back later.</p>
769
+ <button className="btn-secondary" onClick={() => goToStep('dates')}>Change dates</button>
770
+ </div>
771
+ </>
772
+ );
773
+ }
774
+ return (
775
+ <>
776
+ <h2 className="step-title">Choose Your Room</h2>
777
+ <p className="step-subtitle">Each space is crafted for an unforgettable experience</p>
778
+ <div className="room-grid-wrapper">
779
+ <button className="room-nav-btn room-nav-prev" onClick={() => scrollRooms(-1)} aria-label="Previous rooms">
780
+ <ChevronLeft size={24} />
781
+ </button>
782
+ <div className="room-grid" ref={roomGridRef}>
783
+ {roomsList.map(r => {
784
+ const sel = state.selectedRoom?.id === r.id;
785
+ return (
786
+ <div key={r.id} className={`room-card ${sel ? 'selected' : ''}`} onClick={() => selectRoom(r.id)} style={{flex: '0 0 280px', minWidth: '280px'}}>
787
+ <div className="room-card-img-wrap">
788
+ <img className="room-card-img" src={r.image} alt={r.name} onError={(e) => { e.target.src = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'; }} />
789
+ {sel && <div className="room-card-check"><Check size={14} /></div>}
790
+ </div>
791
+ <div className="room-card-body">
792
+ <div className="room-card-top">
793
+ <span className="room-card-name">{r.name}</span>
794
+ <div className="room-card-price">
795
+ <strong>{formatPrice(getNights() > 0 ? r.basePrice / getNights() : r.basePrice, r.currency)}</strong>
796
+ <small>/ night</small>
797
+ </div>
798
+ </div>
799
+ <p className="room-card-desc">{r.description}</p>
800
+ <div className="room-card-meta">
801
+ <span style={{display: 'flex', alignItems: 'center', gap: '0.25em'}}>
802
+ <Square size={14} /> {formatRoomSize(r.size)}
803
+ </span>
804
+ <span style={{display: 'flex', alignItems: 'center', gap: '0.25em'}}>
805
+ <User size={14} /> Up to {r.maxGuests}
806
+ </span>
807
+ </div>
808
+ <div className="amenity-tags">{(r.amenities || []).slice(0, 5).map(a => <span key={a} className="amenity-tag">{a}</span>)}</div>
809
+ </div>
810
+ </div>
811
+ );
812
+ })}
813
+ </div>
814
+ <button className="room-nav-btn room-nav-next" onClick={() => scrollRooms(1)} aria-label="Next rooms">
815
+ <ChevronRight size={24} />
816
+ </button>
817
+ </div>
818
+ <button className="btn-primary" disabled={!state.selectedRoom} onClick={() => goToStep('rates')}>
819
+ Select Rate
820
+ </button>
821
+ </>
822
+ );
823
+ };
824
+
825
+ const renderRatesStep = () => {
826
+ if (loadingRates) {
827
+ return (
828
+ <>
829
+ <p className="step-subtitle">Loading rates...</p>
830
+ <div style={{ padding: '2em', textAlign: 'center', color: 'var(--muted)' }}>Please wait.</div>
831
+ </>
832
+ );
833
+ }
834
+ if (apiError) {
835
+ return (
836
+ <>
837
+ <p className="step-subtitle" style={{ color: 'var(--destructive, #ef4444)' }}>{apiError}</p>
838
+ <button className="btn-secondary" onClick={() => goToStep('rates')}>Try again</button>
839
+ </>
840
+ );
841
+ }
842
+ if (ratesList.length === 0) {
843
+ const room = state.selectedRoom;
844
+ return (
845
+ <>
846
+ <div className="rate-step-card">
847
+ {room && (
848
+ <div className="rate-step-room-summary">
849
+ <img className="rate-step-room-summary-image" src={room.image} alt="" onError={(e) => { e.target.src = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'; }} />
850
+ <div className="rate-step-room-summary-body">
851
+ <h3 className="rate-step-room-summary-name">{room.name}</h3>
852
+ {room.description && <p className="rate-step-room-summary-desc">{room.description}</p>}
853
+ <div className="rate-step-room-summary-meta">
854
+ {room.size && <span>{formatRoomSize(room.size)}</span>}
855
+ {room.maxGuests != null && <span>Up to {room.maxGuests} guests</span>}
856
+ </div>
857
+ </div>
858
+ </div>
859
+ )}
860
+ <p style={{ padding: '1em', color: 'var(--muted)', fontSize: '0.9em', margin: 0 }}>No rates available for this room.</p>
861
+ </div>
862
+ <button className="btn-secondary" onClick={() => goToStep('rooms')}>Choose another room</button>
863
+ </>
864
+ );
865
+ }
866
+ const base = state.selectedRoom?.basePrice ?? 0;
867
+ const room = state.selectedRoom;
868
+ return (
869
+ <>
870
+ <div className="rate-step-card">
871
+ {room && (
872
+ <div className="rate-step-room-summary">
873
+ <img className="rate-step-room-summary-image" src={room.image} alt="" onError={(e) => { e.target.src = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'; }} />
874
+ <div className="rate-step-room-summary-body">
875
+ <h3 className="rate-step-room-summary-name">{room.name}</h3>
876
+ {room.description && <p className="rate-step-room-summary-desc">{room.description}</p>}
877
+ <div className="rate-step-room-summary-meta">
878
+ {room.size && <span>{formatRoomSize(room.size)}</span>}
879
+ {room.maxGuests != null && <span>Up to {room.maxGuests} guests</span>}
880
+ </div>
881
+ </div>
882
+ </div>
883
+ )}
884
+ <div className="rate-list">
885
+ {ratesList.map(r => {
886
+ const sel = state.selectedRate?.id === r.id;
887
+ const total = Math.round(base * r.priceModifier * state.rooms);
888
+ return (
889
+ <div key={r.id} className={`rate-card ${sel ? 'selected' : ''}`} onClick={() => selectRate(r.id)}>
890
+ {r.recommended && <div className="rate-badge"><Star size={12} /> Recommended</div>}
891
+ <div className="rate-top">
892
+ <div style={{display: 'flex', gap: '0.75em', alignItems: 'center', flex: 1, flexWrap: 'wrap'}}>
893
+ <div className="rate-radio"><div className="rate-radio-dot"></div></div>
894
+ <span className="rate-name">{[r.policy, ...(r.benefits || [])].filter(Boolean).join(' ')}</span>
895
+ <div className="rate-benefits"><span className="amenity-tag">{r.rate_code ?? r.name}</span></div>
896
+ </div>
897
+ <div className="rate-price"><strong>{formatPrice(total, state.selectedRoom?.currency)}</strong><small>total</small></div>
898
+ </div>
899
+ {r.policy && (
900
+ <p className="rate-policy" style={{fontSize: '0.8em', color: 'var(--muted)', marginTop: '0.5em', display: 'flex', alignItems: 'center', gap: '0.35em'}}>
901
+ <span>{r.policy}</span>
902
+ <span className="policy-info-icon" title={r.policyDetail || r.policy} aria-label="Policy details">
903
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
904
+ </span>
905
+ </p>
906
+ )}
907
+ </div>
908
+ );
909
+ })}
910
+ </div>
911
+ </div>
912
+ <button className="btn-primary" disabled={!state.selectedRate} onClick={() => goToStep('summary')}>
913
+ Proceed to Summary
914
+ </button>
915
+ </>
916
+ );
917
+ };
918
+
919
+ const renderSummaryStep = () => {
920
+ const nights = getNights();
921
+ const total = getTotalPrice();
922
+ const canSubmit = state.guest.firstName && state.guest.lastName && state.guest.email;
923
+ const hasStripe = stripePublishableKey && typeof createPaymentIntent === 'function';
924
+ return (
925
+ <>
926
+ <h2 className="step-title">Review Your Booking</h2>
927
+ <p className="step-subtitle">Confirm your details before payment</p>
928
+ <div className="checkout-grid">
929
+ <div>
930
+ <h3 style={{fontSize: '0.65em', textTransform: 'uppercase', letterSpacing: '0.2em', color: 'var(--muted)', paddingBottom: '0.5em', borderBottom: '1px solid var(--border)', marginBottom: '1em', fontFamily: 'var(--font-sans)', fontWeight: 500}}>Guest Information</h3>
931
+ <div className="form-row">
932
+ <div className="form-group">
933
+ <label className="form-label">First Name</label>
934
+ <input className="form-input" value={state.guest.firstName} onChange={(e) => updateGuest('firstName', e.target.value)} placeholder="James" />
935
+ </div>
936
+ <div className="form-group">
937
+ <label className="form-label">Last Name</label>
938
+ <input className="form-input" value={state.guest.lastName} onChange={(e) => updateGuest('lastName', e.target.value)} placeholder="Bond" />
939
+ </div>
940
+ </div>
941
+ <div className="form-group">
942
+ <label className="form-label">Email</label>
943
+ <input className="form-input" type="email" value={state.guest.email} onChange={(e) => updateGuest('email', e.target.value)} placeholder="james@example.com" />
944
+ </div>
945
+ <div className="form-group">
946
+ <label className="form-label">Phone</label>
947
+ <input className="form-input" type="tel" value={state.guest.phone} onChange={(e) => updateGuest('phone', e.target.value)} placeholder="+1 (555) 000-0000" />
948
+ </div>
949
+ <div className="form-group">
950
+ <label className="form-label">Special Requests</label>
951
+ <textarea className="form-textarea" rows="3" value={state.guest.specialRequests} onChange={(e) => updateGuest('specialRequests', e.target.value)} placeholder="Any special requests..."></textarea>
952
+ </div>
953
+ </div>
954
+ <div>
955
+ <div className="summary-box">
956
+ <h3>Booking Summary</h3>
957
+ {state.selectedRoom && (
958
+ <>
959
+ <img src={state.selectedRoom.image} alt="" style={{width: '100%', height: '8em', objectFit: 'cover', borderRadius: '0.5em', marginBottom: '0.75em'}} onError={(e) => { e.target.src = 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'; }} />
960
+ <p style={{fontFamily: 'var(--font-serif)', marginBottom: '1em'}}>{state.selectedRoom.name}</p>
961
+ </>
962
+ )}
963
+ <div className="summary-row"><span>Check-in</span><span>{fmt(state.checkIn)}</span></div>
964
+ <div className="summary-row"><span>Check-out</span><span>{fmt(state.checkOut)}</span></div>
965
+ {(() => { const g = getTotalGuests(); return <div className="summary-row"><span>Guests</span><span>{g.adults} adults{g.children ? ', ' + g.children + ' children' : ''}</span></div>; })()}
966
+ <div className="summary-row"><span>Rate</span><span>{state.selectedRate ? [state.selectedRate.policy, ...(state.selectedRate.benefits || [])].filter(Boolean).join(' ') : '—'}</span></div>
967
+ {((state.selectedRate?.fees?.length > 0) || state.selectedRate?.vat) && (() => {
968
+ const nights = getNights();
969
+ const rooms = state.rooms;
970
+ const roomTotal = Math.round(state.selectedRoom.basePrice * state.selectedRate.priceModifier * rooms);
971
+ const currency = state.selectedRoom?.currency;
972
+ const fees = state.selectedRate.fees ?? [];
973
+ const allIncluded = fees.length > 0 && fees.every(f => f.included) && (!state.selectedRate.vat || state.selectedRate.vat.included);
974
+ return (
975
+ <>
976
+ <div className="summary-row"><span>Room total</span><span>{formatPrice(roomTotal, currency)}</span></div>
977
+ <div className="summary-fees">
978
+ <p className="summary-fees-heading">Fees & taxes</p>
979
+ {allIncluded && <p className="summary-fees-note">Included in your rate</p>}
980
+ <div className="summary-fees-list">
981
+ {fees.map((f, i) => (
982
+ <div key={i} className="summary-row summary-row--fee">
983
+ <span className="summary-fee-label">
984
+ {f.name}{' '}
985
+ <span className={f.included ? 'summary-fee-badge' : 'summary-fee-badge summary-fee-badge--excluded'}>
986
+ {f.included ? 'Included' : 'Excluded'}
987
+ </span>
988
+ </span>
989
+ <span>{formatPrice(f.perNight ? f.amount * nights * rooms : f.amount, currency)}</span>
990
+ </div>
991
+ ))}
992
+ {state.selectedRate.vat && (
993
+ <div className="summary-row summary-row--fee">
994
+ <span className="summary-fee-label">
995
+ VAT
996
+ {state.selectedRate.vat.value !== 0 && state.selectedRate.vat.value != null && (
997
+ <>
998
+ {' '}
999
+ <span className={state.selectedRate.vat.included ? 'summary-fee-badge' : 'summary-fee-badge summary-fee-badge--excluded'}>
1000
+ {state.selectedRate.vat.included ? 'Included' : 'Excluded'}
1001
+ </span>
1002
+ </>
1003
+ )}
1004
+ </span>
1005
+ <span>{formatPrice(state.selectedRate.vat.value, currency)}</span>
1006
+ </div>
1007
+ )}
1008
+ </div>
1009
+ </div>
1010
+ </>
1011
+ );
1012
+ })()}
1013
+ <div className="summary-total">
1014
+ <span className="summary-total-label">Total</span>
1015
+ <span className="summary-total-price">{formatPrice(total, state.selectedRoom?.currency)}</span>
1016
+ </div>
1017
+ {apiError && <p style={{ color: 'var(--destructive, #ef4444)', fontSize: '0.85em', marginTop: '0.5em' }}>{apiError}</p>}
1018
+ <button
1019
+ className="btn-primary"
1020
+ style={{maxWidth: '100%', marginTop: '1em'}}
1021
+ disabled={!canSubmit}
1022
+ onClick={confirmReservation}
1023
+ >
1024
+ {hasStripe ? (
1025
+ <>Proceed to Payment</>
1026
+ ) : (
1027
+ <>Confirm Reservation</>
1028
+ )}
1029
+ </button>
1030
+ <p className="secure-note" style={{display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5em'}}>
1031
+ <Lock size={14} /> Secure & encrypted booking
1032
+ </p>
1033
+ </div>
1034
+ </div>
1035
+ </div>
1036
+ </>
1037
+ );
1038
+ };
1039
+
1040
+ const renderPaymentStep = () => {
1041
+ const total = getTotalPrice();
1042
+ return (
1043
+ <div className="payment-step">
1044
+ <button type="button" className="payment-step-back" onClick={() => goToStep('summary')} aria-label="Back to summary">
1045
+ <ChevronLeft size={16} /> Back to summary
1046
+ </button>
1047
+ <h2 className="step-title">Payment</h2>
1048
+ <p className="step-subtitle">Enter your payment details to confirm your reservation</p>
1049
+ <div className="checkout-grid">
1050
+ <div className="payment-step-form">
1051
+ <div className="checkout-payment-section">
1052
+ <h3 style={{fontSize: '0.65em', textTransform: 'uppercase', letterSpacing: '0.2em', color: 'var(--muted)', marginBottom: '0.5em', fontFamily: 'var(--font-sans)', fontWeight: 500}}>Card details</h3>
1053
+ {!paymentElementReady && !apiError && (
1054
+ <p className="payment-loading-placeholder" style={{ fontSize: '0.85em', color: 'var(--muted)', margin: 0 }}>Loading payment form…</p>
1055
+ )}
1056
+ <PaymentElementContainer />
1057
+ {apiError && (
1058
+ <p className="payment-setup-error" style={{ fontSize: '0.85em', color: 'var(--destructive, #ef4444)', margin: 0 }}>
1059
+ Payment form could not load. Your backend must create a Stripe Payment Intent and return <code style={{ fontSize: '0.8em' }}>{'{ clientSecret }'}</code>. Check the endpoint and try again.
1060
+ </p>
1061
+ )}
1062
+ </div>
1063
+ </div>
1064
+ <div className="payment-step-summary">
1065
+ <h3>Amount due</h3>
1066
+ <div className="payment-total-row">
1067
+ <span className="payment-total-label">Total</span>
1068
+ <span className="payment-total-amount">{formatPrice(total, state.selectedRoom?.currency)}</span>
1069
+ </div>
1070
+ <button
1071
+ className="btn-primary"
1072
+ disabled={!paymentElementReady}
1073
+ onClick={confirmReservation}
1074
+ >
1075
+ <CreditCard size={18} />
1076
+ {!paymentElementReady ? 'Loading payment…' : 'Confirm Reservation'}
1077
+ </button>
1078
+ <p className="secure-note">
1079
+ <Lock size={14} /> Secure & encrypted booking
1080
+ </p>
1081
+ </div>
1082
+ </div>
1083
+ </div>
1084
+ );
1085
+ };
1086
+
1087
+ const renderConfirmationStep = () => {
1088
+ if (!confirmationDetails) {
1089
+ return (
1090
+ <div className="confirmation">
1091
+ <div className="confirmation-loader">
1092
+ <div className="confirmation-loader-spinner" />
1093
+ <h2 className="step-title">Finalizing Reservation</h2>
1094
+ <p className="step-subtitle" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9em' }}>We're confirming your booking. This usually takes a few seconds.</p>
1095
+ {confirmationStatus ? <span style={{ fontSize: '0.8em', color: 'var(--muted)' }}>Status: {confirmationStatus}</span> : null}
1096
+ </div>
1097
+ </div>
1098
+ );
1099
+ }
1100
+ const nights = confirmationDetails?.nights ?? getNights();
1101
+ const total = (confirmationDetails?.totalAmount != null && confirmationDetails?.totalAmount !== '') ? Number(confirmationDetails.totalAmount) : getTotalPrice();
1102
+ const currency = confirmationDetails?.currency || state.selectedRoom?.currency || 'USD';
1103
+ const bookingId = confirmationDetails?.bookingId ?? confirmationDetails?.booking_id ?? null;
1104
+ const hotelName = confirmationDetails?.hotelName ?? confirmationDetails?.hotel_name ?? null;
1105
+ const roomTypeRaw = confirmationDetails?.roomType ?? confirmationDetails?.room_type ?? state.selectedRoom?.name ?? '';
1106
+ const roomType = roomTypeRaw.replace(/\s*Rate\s*\d+\s*$/i, '').trim();
1107
+ const checkInDisplay = confirmationDetails?.checkIn ? (typeof confirmationDetails.checkIn === 'string' ? new Date(confirmationDetails.checkIn) : confirmationDetails.checkIn) : state.checkIn;
1108
+ const checkOutDisplay = confirmationDetails?.checkOut ? (typeof confirmationDetails.checkOut === 'string' ? new Date(confirmationDetails.checkOut) : confirmationDetails.checkOut) : state.checkOut;
1109
+ const nightsLabel = nights === 1 ? '1 night' : `${nights} nights`;
1110
+ return (
1111
+ <div className="confirmation">
1112
+ <div className="confirm-icon"><Check size={56} /></div>
1113
+ <h2 className="step-title">Reservation Confirmed</h2>
1114
+ <p className="step-subtitle">Thank you, {state.guest.firstName}. We look forward to welcoming you.</p>
1115
+ <div className="confirm-box">
1116
+ {bookingId && (
1117
+ <div className="confirm-header">
1118
+ <div className="confirm-booking-id">
1119
+ <span className="confirm-booking-id-label">Booking ID</span>
1120
+ <span className="confirm-booking-id-value">{bookingId}</span>
1121
+ </div>
1122
+ </div>
1123
+ )}
1124
+ <div className="confirm-detail">
1125
+ <span className="confirm-detail-icon"><Calendar size={18} /></span>
1126
+ <div className="confirm-detail-content">
1127
+ <span>{checkInDisplay ? fmtLong(checkInDisplay) : ''} to {checkOutDisplay ? fmtLong(checkOutDisplay) : ''}</span>
1128
+ <small>{nightsLabel}</small>
1129
+ </div>
1130
+ </div>
1131
+ {hotelName && (
1132
+ <div className="confirm-detail">
1133
+ <span className="confirm-detail-icon"><MapPin size={18} /></span>
1134
+ <div className="confirm-detail-content"><span>{hotelName}</span></div>
1135
+ </div>
1136
+ )}
1137
+ <div className="confirm-detail">
1138
+ <span className="confirm-detail-icon"><MapPin size={18} /></span>
1139
+ <div className="confirm-detail-content">
1140
+ <span>{roomType}</span>
1141
+ {state.selectedRate && (
1142
+ <span className="confirm-detail-rate-line">
1143
+ {[state.selectedRate.policy, ...(state.selectedRate.benefits || [])].filter(Boolean).join(' · ')}
1144
+ </span>
1145
+ )}
1146
+ </div>
1147
+ </div>
1148
+ <div className="confirm-detail">
1149
+ <span className="confirm-detail-icon"><Phone size={18} /></span>
1150
+ <div className="confirm-detail-content">
1151
+ <span>{state.guest.firstName} {state.guest.lastName}</span>
1152
+ <small>{state.guest.email}</small>
1153
+ </div>
1154
+ </div>
1155
+ <div className="summary-total">
1156
+ <span className="summary-total-label">Total Charged</span>
1157
+ <span className="summary-total-price">{formatPrice(total, currency)}</span>
1158
+ </div>
1159
+ </div>
1160
+ <p style={{fontSize: '0.75em', color: 'var(--muted)', marginBottom: '1.5em'}}>A confirmation email has been sent to {state.guest.email}</p>
1161
+ <button className="btn-secondary" onClick={resetBooking}>Book Another Stay</button>
1162
+ </div>
1163
+ );
1164
+ };
1165
+
1166
+ const isVisible = isOpen && !isClosing && isReadyForOpen;
1167
+ if (!isOpen && !isClosing) return null;
1168
+
1169
+ return (
1170
+ <>
1171
+ <div className={`booking-widget-overlay ${isVisible ? 'active' : ''}`} onClick={requestClose}></div>
1172
+ <div
1173
+ ref={widgetRef}
1174
+ className={`booking-widget-modal ${isVisible ? 'active' : ''}`}
1175
+ onTransitionEnd={handleTransitionEnd}
1176
+ >
1177
+ <button className="booking-widget-close" onClick={requestClose}><X size={24} /></button>
1178
+ {renderStepIndicator()}
1179
+ <div className="booking-widget-step-content">
1180
+ {state.step === 'dates' && renderDatesStep()}
1181
+ {state.step === 'rooms' && renderRoomsStep()}
1182
+ {state.step === 'rates' && renderRatesStep()}
1183
+ {state.step === 'summary' && renderSummaryStep()}
1184
+ {state.step === 'payment' && renderPaymentStep()}
1185
+ {state.step === 'confirmation' && renderConfirmationStep()}
1186
+ </div>
1187
+ </div>
1188
+ </>
1189
+ );
1190
+ };
1191
+
1192
+ export default BookingWidget;