pxsol-booking-search-widget
Version:
Embeddable booking engine search widget with React and Web Component builds.
1 lines • 138 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/components/Calendar.tsx","../src/core/format.ts","../src/core/state.ts","../src/Widget.tsx"],"sourcesContent":["import React, { useState, useMemo, useEffect } from 'react';\nimport css from './Calendar.module.css';\n\ninterface CalendarProps {\n checkInDate: string | undefined;\n checkOutDate: string | undefined;\n minDate?: string; // ISO local date (YYYY-MM-DD) minimum selectable date\n numberOfNights?: number;\n editingMode?: 'checkin' | 'checkout';\n onDateSelect: (date: string) => void;\n onNightAdjust?: (delta: number) => void;\n onClose: () => void;\n onDone?: () => void;\n}\n\ninterface DateInfo {\n date: Date;\n day: number;\n isCurrentMonth: boolean;\n isToday: boolean;\n isWeekend: boolean;\n isPast: boolean;\n isSelected: boolean;\n isInRange: boolean;\n isCheckIn: boolean;\n isCheckOut: boolean;\n isDisabled: boolean;\n}\n\nconst MONTHS = [\n 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',\n 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'\n];\n\nconst DAYS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];\n\nexport const Calendar: React.FC<CalendarProps> = ({ \n checkInDate, \n checkOutDate, \n minDate,\n numberOfNights = 1,\n editingMode,\n onDateSelect,\n onNightAdjust,\n onClose,\n onDone\n}) => {\n const today = new Date();\n const currentMonth = today.getMonth();\n const currentYear = today.getFullYear();\n\n const [viewingMonth, setViewingMonth] = useState(currentMonth);\n const [viewingYear, setViewingYear] = useState(currentYear);\n\n useEffect(() => {\n // Debug props\n console.log('[booking-search-widget][Calendar] props', { checkInDate, checkOutDate, minDate });\n }, [checkInDate, checkOutDate, minDate]);\n\n const toLocalYMD = (date: Date): string => {\n const y = date.getFullYear();\n const m = String(date.getMonth() + 1).padStart(2, '0');\n const d = String(date.getDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n };\n\n const parseDate = (dateStr: string | undefined): Date | null => {\n if (!dateStr) return null;\n const [y, m, d] = dateStr.split('-').map(Number);\n return new Date(y, (m || 1) - 1, d || 1);\n };\n\n const checkIn = parseDate(checkInDate);\n const checkOut = parseDate(checkOutDate);\n\n const generateMonthDays = (month: number, year: number): DateInfo[] => {\n const firstDay = new Date(year, month, 1);\n const lastDay = new Date(year, month + 1, 0);\n const startDate = new Date(firstDay);\n startDate.setDate(startDate.getDate() - firstDay.getDay());\n\n const days: DateInfo[] = [];\n const currentDate = new Date(startDate);\n\n // Generate 42 days (6 weeks)\n for (let i = 0; i < 42; i++) {\n const date = new Date(currentDate);\n const day = date.getDate();\n const isCurrentMonth = date.getMonth() === month;\n const isToday = toLocalYMD(date) === toLocalYMD(today);\n const minSelectable = minDate ? parseDate(minDate) : today;\n const isPast = toLocalYMD(date) < toLocalYMD(minSelectable ?? today);\n const isWeekend = date.getDay() === 0 || date.getDay() === 6;\n \n const dateStr = toLocalYMD(date);\n const isCheckIn = checkInDate === dateStr;\n const checkOutMatches = checkOutDate === dateStr;\n \n // Validar que el check-out sea válido (posterior al check-in)\n let isCheckOut = false;\n if (checkOutMatches) {\n if (checkIn && checkInDate && checkOutDate) {\n const checkInDateObj = parseDate(checkInDate);\n const checkOutDateObj = parseDate(checkOutDate);\n if (checkOutDateObj && checkInDateObj && checkOutDateObj > checkInDateObj) {\n isCheckOut = true;\n }\n }\n }\n \n const isSelected = isCheckIn || isCheckOut;\n \n // Mostrar el rango solo si hay check-in y check-out, y el check-out es válido\n let isInRange = false;\n if (checkIn && checkOut && checkInDate && checkOutDate) {\n const checkInDateObj = parseDate(checkInDate);\n const checkOutDateObj = parseDate(checkOutDate);\n // Solo mostrar el rango si el check-out es posterior al check-in\n if (checkInDateObj && checkOutDateObj && checkOutDateObj > checkInDateObj) {\n const minDate = checkIn < checkOut ? checkIn : checkOut;\n const maxDate = checkIn > checkOut ? checkIn : checkOut;\n isInRange = date > minDate && date < maxDate;\n }\n }\n\n // Disable past dates (except today) and dates from other months\n // Only disable dates before or equal to check-in if we're editing checkout\n let isDisabled = isPast || (!isCurrentMonth);\n if (editingMode === 'checkout' && checkIn && checkInDate && dateStr <= checkInDate && !isCheckIn) {\n isDisabled = true;\n }\n\n days.push({\n date,\n day,\n isCurrentMonth,\n isToday,\n isWeekend,\n isPast,\n isSelected,\n isInRange,\n isCheckIn,\n isCheckOut,\n isDisabled\n });\n\n currentDate.setDate(currentDate.getDate() + 1);\n }\n\n return days;\n };\n\n const monthDays = useMemo(() => \n generateMonthDays(viewingMonth, viewingYear), \n [viewingMonth, viewingYear, checkInDate, checkOutDate, minDate, editingMode]\n );\n\n const handleDateClick = (dateInfo: DateInfo) => {\n if (dateInfo.isDisabled) return;\n \n const dateStr = toLocalYMD(dateInfo.date);\n console.log('[booking-search-widget][Calendar] onDateSelect', { dateStr, editingMode });\n \n // Pasar la fecha seleccionada al componente padre para que maneje la lógica\n onDateSelect(dateStr);\n };\n\n const goToNextMonth = () => {\n if (viewingMonth === 11) {\n setViewingMonth(0);\n setViewingYear(viewingYear + 1);\n } else {\n setViewingMonth(viewingMonth + 1);\n }\n };\n\n const goToPrevMonth = () => {\n if (viewingMonth === 0) {\n setViewingMonth(11);\n setViewingYear(viewingYear - 1);\n } else {\n setViewingMonth(viewingMonth - 1);\n }\n };\n\n const getMonthCards = () => {\n const cards = [] as JSX.Element[];\n const firstMonth = viewingMonth;\n const firstYear = viewingYear;\n const secondMonth = (viewingMonth + 1) % 12;\n const secondYear = viewingMonth === 11 ? viewingYear + 1 : viewingYear;\n\n const pairs: Array<[number, number]> = [\n [firstMonth, firstYear],\n [secondMonth, secondYear],\n ];\n\n for (const [month, year] of pairs) {\n const daysForMonth = generateMonthDays(month, year);\n cards.push(\n <div key={`${year}-${month}`} className={css.monthCard}>\n <div className={css.monthHeader}>\n <h3 className={css.monthTitle}>\n {MONTHS[month]} {year}\n </h3>\n </div>\n <div className={css.calendarGrid}>\n {DAYS.map((day) => (\n <div key={day} className={css.dayHeader}>\n {day}\n </div>\n ))}\n {daysForMonth.map((dateInfo, index) => (\n <button\n type=\"button\"\n key={index}\n className={`\n ${css.dayButton}\n ${!dateInfo.isCurrentMonth ? css.otherMonth : ''}\n ${dateInfo.isPast ? css.pastDay : ''}\n ${dateInfo.isWeekend ? css.weekend : ''}\n ${dateInfo.isToday ? css.today : ''}\n ${dateInfo.isSelected ? css.selected : ''}\n ${dateInfo.isInRange ? css.inRange : ''}\n ${dateInfo.isCheckIn ? css.checkIn : ''}\n ${dateInfo.isCheckOut ? css.checkOut : ''}\n ${dateInfo.isDisabled ? css.disabled : ''}\n `}\n onClick={() => handleDateClick(dateInfo)}\n disabled={dateInfo.isDisabled}\n >\n {dateInfo.day}\n </button>\n ))}\n </div>\n </div>\n );\n }\n return cards;\n };\n\n const handleOverlayMouseDown: React.MouseEventHandler<HTMLDivElement> = (event) => {\n if (event.target === event.currentTarget) {\n onClose();\n }\n };\n\n return (\n <div className={css.calendarModal} onMouseDown={(e) => e.stopPropagation()}>\n <div className={css.calendarHeader}>\n <button type=\"button\" className={css.navButton} onClick={goToPrevMonth} aria-label=\"Mes anterior\">\n <svg viewBox=\"0 0 24 24\" aria-hidden width=\"18\" height=\"18\">\n <path d=\"M15.5 19l-7-7 7-7\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n </svg>\n </button>\n <h2>Seleccionar fechas</h2>\n <button type=\"button\" className={css.navButton} onClick={goToNextMonth} aria-label=\"Mes siguiente\">\n <svg viewBox=\"0 0 24 24\" aria-hidden width=\"18\" height=\"18\">\n <path d=\"M8.5 5l7 7-7 7\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n </svg>\n </button>\n <button type=\"button\" className={css.closeButton} onClick={onClose} aria-label=\"Cerrar calendario\">×</button>\n </div>\n \n <div className={css.calendarContent}>\n <div className={css.monthCards}>\n {getMonthCards()}\n </div>\n </div>\n \n <div className={css.calendarFooter}>\n <div className={css.calendarFooterTop}>\n <div className={css.dateSummary}>\n {checkInDate && (\n <div className={css.selectedDate}>\n <span className={css.dateLabel}>Check-in:</span>\n <span className={css.dateValue}>\n {parseDate(checkInDate)?.toLocaleDateString('es-ES', {\n day: 'numeric',\n month: 'long'\n })}\n </span>\n </div>\n )}\n {checkOutDate && (\n <div className={css.selectedDate}>\n <span className={css.dateLabel}>Check-out:</span>\n <span className={css.dateValue}>\n {parseDate(checkOutDate)?.toLocaleDateString('es-ES', {\n day: 'numeric',\n month: 'long'\n })}\n </span>\n </div>\n )}\n </div>\n \n <button \n type=\"button\" \n className={css.doneButton} \n onClick={() => {\n if (onDone) {\n onDone();\n } else {\n onClose();\n }\n }}\n aria-label=\"Aplicar selección\"\n >\n Listo\n </button>\n </div>\n \n {/* Night Adjustment Controls */}\n {checkInDate && checkOutDate && onNightAdjust && (\n <div className={css.nightAdjustment}>\n <span className={css.nightAdjustmentLabel}>Ajustar noches:</span>\n <div className={css.nightAdjustmentControls}>\n <button \n type=\"button\"\n className={css.nightAdjustmentBtn}\n onClick={() => onNightAdjust(-1)}\n disabled={numberOfNights <= 1}\n aria-label=\"Reducir una noche\"\n >\n −\n </button>\n <span className={css.nightAdjustmentValue}>\n {numberOfNights} {numberOfNights === 1 ? 'noche' : 'noches'}\n </span>\n <button \n type=\"button\"\n className={css.nightAdjustmentBtn}\n onClick={() => onNightAdjust(1)}\n aria-label=\"Aumentar una noche\"\n >\n +\n </button>\n </div>\n </div>\n )}\n </div>\n </div>\n );\n};\n","import { GroupSpec } from './types';\n\nexport const DEFAULT_GROUP: GroupSpec = {\n rooms: 1,\n adults: 2,\n childrenAges: [],\n infants: 0,\n};\n\nconst GROUP_SEPARATOR = ';';\nconst CHILD_SEPARATOR = '.';\n\nconst safeInt = (value: string, fallback = 0) => {\n const parsed = Number.parseInt(value, 10);\n return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;\n};\n\nconst safeFloat = (value: string) => {\n const parsed = Number.parseFloat(value);\n return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;\n};\n\nexport const parseGroupsForm = (input?: string): GroupSpec[] => {\n if (!input) return [DEFAULT_GROUP];\n\n return input\n .split(GROUP_SEPARATOR)\n .map((segment) => segment.trim())\n .filter(Boolean)\n .map((segment) => {\n const [roomsRaw, rest] = segment.split(':');\n const [adultsRaw, childrenRaw = '0', infantsRaw = '0'] = (rest ?? '').split(',');\n const childrenAges = childrenRaw\n .split(CHILD_SEPARATOR)\n .map((age) => age.trim())\n .filter(Boolean)\n .map((age) => safeFloat(age))\n .filter((age) => age >= 0);\n\n return {\n rooms: Math.max(1, safeInt(roomsRaw, 1)),\n adults: Math.max(1, safeInt(adultsRaw, 1)),\n childrenAges,\n infants: Math.max(0, safeInt(infantsRaw, 0)),\n } satisfies GroupSpec;\n });\n};\n\nexport const formatGroupsForm = (groups: GroupSpec[]): string => {\n const result = groups\n .map((group) => {\n // Include individual child ages - API format: rooms:adults,age.age.age,infants\n const validChildren = group.childrenAges\n .filter((age) => typeof age === 'number' && Number.isFinite(age) && age >= 0)\n .map((age) => {\n if (Number.isInteger(age)) {\n return String(age);\n }\n const normalized = Number(age.toFixed(2));\n return normalized % 1 === 0 ? String(normalized) : normalized.toString();\n });\n\n const children = validChildren.join(CHILD_SEPARATOR);\n\n const groupForm = `${group.rooms}:${group.adults},${children},${group.infants}`;\n return groupForm;\n })\n .join(GROUP_SEPARATOR);\n\n return result;\n};\n\nexport const summarizeGuests = (groups: GroupSpec[]) => {\n const summary = groups.reduce(\n (acc, group) => {\n acc.rooms += group.rooms;\n acc.adults += group.adults;\n acc.children += group.childrenAges.length;\n acc.infants += group.infants;\n return acc;\n },\n { rooms: 0, adults: 0, children: 0, infants: 0 }\n );\n return summary;\n};\n\nexport const cloneGroups = (groups: GroupSpec[]): GroupSpec[] =>\n groups.map((group) => ({ ...group, childrenAges: [...group.childrenAges] }));\n","import {\n WidgetConfig,\n WidgetState,\n ValidationResult,\n SearchRequest,\n GroupSpec,\n WidgetEvent,\n WidgetEventType,\n} from './types';\nimport { cloneGroups, DEFAULT_GROUP, formatGroupsForm, parseGroupsForm } from './format';\n\nconst ISO_DATE_PATTERN = /\\d{4}-\\d{2}-\\d{2}/;\n\nconst todayIso = () => {\n const now = new Date();\n now.setHours(0, 0, 0, 0);\n const y = now.getFullYear();\n const m = String(now.getMonth() + 1).padStart(2, '0');\n const d = String(now.getDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n};\n\nconst addDays = (iso: string, days: number) => {\n const [year, month, day] = iso.split('-').map(Number);\n const date = new Date(year, month - 1, day);\n date.setDate(date.getDate() + days);\n const y = date.getFullYear();\n const m = String(date.getMonth() + 1).padStart(2, '0');\n const d = String(date.getDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n};\n\nconst ensureIsoDate = (value: string | undefined, fallback: string) =>\n value && ISO_DATE_PATTERN.test(value) ? value : fallback;\n\nconst DEFAULT_LOCALE = 'en';\nconst DEFAULT_CURRENCY = 'USD';\nconst DEFAULT_POS = 'GLOBAL';\nconst DEFAULT_PRODUCT_ID = 1000;\n\nexport const createInitialState = (config: WidgetConfig): WidgetState => {\n const baseStart = ensureIsoDate(config.initialStart, todayIso());\n const baseEnd = ensureIsoDate(config.initialEnd, addDays(baseStart, 1));\n\n const startDate = baseStart;\n let endDate = baseEnd;\n if (endDate <= startDate) {\n endDate = addDays(startDate, 1);\n }\n\n const parsedGroups = parseGroupsForm(config.initialGroups);\n const groups = parsedGroups.length ? parsedGroups : [DEFAULT_GROUP];\n\n const productId = config.productId ?? DEFAULT_PRODUCT_ID;\n \n // Load promo code from localStorage if available\n let promoCode: string | undefined = undefined;\n if (typeof window !== 'undefined') {\n try {\n const stored = localStorage.getItem(`booking-widget-promo-code-${productId}`);\n if (stored) {\n promoCode = stored;\n }\n } catch (e) {\n // localStorage may be unavailable\n console.warn('[booking-search-widget] could not read promo code from localStorage', e);\n }\n }\n\n return {\n startDate,\n endDate,\n pos: config.pos ?? DEFAULT_POS,\n locale: config.locale ?? DEFAULT_LOCALE,\n currency: config.currency ?? DEFAULT_CURRENCY,\n productId,\n groups: cloneGroups(groups),\n redirect: Boolean(config.redirect),\n promoCode,\n isSearching: false,\n errors: {},\n };\n};\n\nconst validateDateRange = (start: string, end: string) => {\n if (!ISO_DATE_PATTERN.test(start)) return 'Start date is invalid.';\n if (!ISO_DATE_PATTERN.test(end)) return 'End date is invalid.';\n if (end <= start) return 'End date must be after start date.';\n // Ensure at least 1 night difference\n const startDate = new Date(start);\n const endDate = new Date(end);\n const diffTime = endDate.getTime() - startDate.getTime();\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n if (diffDays < 1) return 'Check-out must be at least 1 day after check-in.';\n return null;\n};\n\nconst validateGroups = (groups: GroupSpec[]) => {\n if (!groups.length) return 'At least one room group is required.';\n for (const [index, group] of groups.entries()) {\n if (group.rooms <= 0) return `Room ${index + 1}: rooms must be greater than 0.`;\n if (group.adults <= 0) return `Room ${index + 1}: adults must be greater than 0.`;\n if (group.childrenAges.some((age) => age < 0)) {\n return `Room ${index + 1}: child ages must be >= 0.`;\n }\n if (group.infants < 0) return `Room ${index + 1}: infants must be >= 0.`;\n }\n return null;\n};\n\nexport const validateState = (state: WidgetState): ValidationResult => {\n const errors: ValidationResult['errors'] = {};\n\n const dateError = validateDateRange(state.startDate, state.endDate);\n if (dateError) errors.dates = dateError;\n\n if (!state.productId || state.productId <= 0) {\n errors.productId = 'Product is required.';\n }\n\n const groupError = validateGroups(state.groups);\n if (groupError) errors.groups = groupError;\n\n if (!state.pos) errors.pos = 'POS is required.';\n if (!state.locale) errors.locale = 'Language is required.';\n if (!state.currency) errors.currency = 'Currency is required.';\n\n return {\n valid: Object.keys(errors).length === 0,\n errors,\n };\n};\n\nexport const buildSearchPayload = (\n state: WidgetState,\n config: WidgetConfig\n): SearchRequest => {\n // Filter out babies and children if hidden\n let groupsToFormat = state.groups;\n if (config.hideBabies || config.hideChildren) {\n groupsToFormat = state.groups.map(group => {\n const filteredGroup = {\n ...group,\n childrenAges: config.hideChildren ? [] : group.childrenAges,\n infants: config.hideBabies ? 0 : group.infants\n };\n return filteredGroup;\n });\n }\n console.log('[booking-search-widget][buildSearchPayload] groups to format:', JSON.stringify(groupsToFormat, null, 2));\n const groups_form = formatGroupsForm(groupsToFormat);\n console.log('[booking-search-widget][buildSearchPayload] formatted groups_form:', groups_form);\n \n // Build payload - product_id must be integer as per API documentation\n const payload: SearchRequest = {\n start_date: state.startDate,\n end_date: state.endDate,\n product_id: Number(state.productId) || 0, // Convert to number as API expects integer\n groups_form,\n pos: state.pos,\n language: state.locale,\n currency: state.currency,\n };\n \n // Only include code if it exists\n if (state.promoCode) {\n payload.code = state.promoCode;\n }\n \n return payload;\n};\n\nexport const emit = (emitter: WidgetConfig['onEvent'] | undefined, event: WidgetEvent) => {\n if (emitter) emitter(event);\n};\n\nexport const forwardEvent = (\n emitter: WidgetConfig['onEvent'] | undefined,\n type: WidgetEventType,\n payload?: unknown\n) => emit(emitter, { type, payload });\n","import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Calendar } from './components/Calendar';\nimport { WidgetConfig, WidgetState, GroupSpec, WidgetEventType, SearchResponse, ProductInfo } from './core/types';\nimport { cloneGroups, formatGroupsForm, summarizeGuests } from './core/format';\nimport { buildSearchPayload, createInitialState, forwardEvent, validateState } from './core/state';\nimport css from './Widget.module.css';\n\nexport type BookingSearchWidgetProps = WidgetConfig;\n\nconst POS_OPTIONS = ['GLOBAL', 'HotelEscuela', 'HotelDelSol'];\nconst LOCALE_OPTIONS = [\n { value: 'en', label: 'English' },\n { value: 'es', label: 'Español' },\n { value: 'pt', label: 'Português' },\n];\nconst CURRENCY_OPTIONS = ['USD', 'EUR', 'ARS', 'BRL'];\n\nconst MOBILE_BREAKPOINT = 767;\n\nconst PRODUCT_INFO_TOKEN = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ill5Mi1oSy1VYktQTFZSci1QSm5heSJ9.eyJnaXZlbl9uYW1lIjoiU2FudGlhZ28iLCJmYW1pbHlfbmFtZSI6IkNhYnJhbCIsIm5pY2tuYW1lIjoic2NhYnJhbCIsIm5hbWUiOiJTYW50aWFnbyBDYWJyYWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSkp2NkxsUFVxVl90NTRobjk1TGlqWTJXcTN3S21uaDNSYjZaZFY4Tzk4MW1XOGgySVc9czk2LWMiLCJ1cGRhdGVkX2F0IjoiMjAyNS0xMS0xMFQxODowNjoyMi40NTZaIiwiZW1haWwiOiJzY2FicmFsQHB4c29sLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJpc3MiOiJodHRwczovL2F1dGgwLnB4c29sLmNvbS8iLCJhdWQiOiJtN2pDRFBhcVFCaGxNM2hRbFVZYnhhUURUVXhvSmtJVyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTA5Njc3NTgxMDcwMTk4NTU4NjI2IiwiaWF0IjoxNzYyNzk3OTgzLCJleHAiOjE3NjMzOTc5ODMsInNpZCI6IldlRVpNRURhVjFzellyWWh0cXdkbDJPaWtERUpRa190Iiwibm9uY2UiOiI3NWU1M2RkZGFhMzczNWYxMzkzODYxMTM4NGE1ZDZkNCJ9.VON_f1eYpEBwG_L3yXkz4IjeLJXdm6XHS5d753Zm5bBcvQc3zPuCyqG_4vHP5KOHK17VeWslkap1TR_yLGJNUB9XPoyTH7KjQrArPtW9P2p-cIO3uADYdv1sGsLFYYjb_KkVcSOUFD8XXbQ-3mgMOXLKqJbUiLT1M12X9v-mZv-OWwkrg0gLTW9hZVaqs3OU0tnwUsfEH3nHge5EdUj5yAGVDi31aDFke-afR2hBlFEZJ4JHuqjtWXD_vlW-C18psYoWSRojlkkP31W5A6M8TILZZm_ck1QoPf8aN6aXF_g6btkhLpuhw7PSuVmImiw4IFlxdcAnv0dYt962Kqf8HA';\n\nconst mergeGroups = (groups: GroupSpec[], index: number, patch: Partial<GroupSpec>): GroupSpec[] => {\n const next = cloneGroups(groups);\n next[index] = {\n ...next[index],\n ...patch,\n childrenAges: patch.childrenAges ?? next[index].childrenAges,\n };\n return next;\n};\n\nconst parseChildrenInput = (value: string): number[] =>\n value\n .split(/[.,]/)\n .map((age) => age.trim())\n .filter(Boolean)\n .map(Number)\n .filter((age) => Number.isFinite(age) && age >= 0);\n\nexport const BookingSearchWidget: React.FC<BookingSearchWidgetProps> = (props) => {\n const [state, setState] = useState<WidgetState>(() => createInitialState(props));\n const hasHydrated = useRef(false);\n const checkinRef = useRef<HTMLDivElement | null>(null);\n const checkoutRef = useRef<HTMLDivElement | null>(null);\n const guestsRef = useRef<HTMLDivElement | null>(null);\n const searchBarRef = useRef<HTMLDivElement | null>(null);\n\n const [checkinOpen, setCheckinOpen] = useState(false);\n const [checkoutOpen, setCheckoutOpen] = useState(false);\n const [guestsOpen, setGuestsOpen] = useState(false);\n const [calendarMode, setCalendarMode] = useState<'checkin' | 'checkout' | null>(null);\n const [showStickyMini, setShowStickyMini] = useState(false);\n const [isMobileFormExpanded, setIsMobileFormExpanded] = useState(false);\n const pendingOpenRef = useRef(false);\n const searchBarVisibleRef = useRef(true);\n const computeInitialViewportMobile = () => {\n if (typeof window === 'undefined') return false;\n if (window.visualViewport && typeof window.visualViewport.width === 'number') {\n return window.visualViewport.width <= MOBILE_BREAKPOINT;\n }\n if (typeof window.matchMedia === 'function') {\n return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;\n }\n return window.innerWidth <= MOBILE_BREAKPOINT;\n };\n const [isViewportMobile, setIsViewportMobile] = useState<boolean>(computeInitialViewportMobile);\n const [isContainerMobile, setIsContainerMobile] = useState<boolean>(false);\n const isMobile = isViewportMobile || isContainerMobile;\n const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(null);\n const containerRef = useCallback((node: HTMLDivElement | null) => {\n setContainerNode((prev) => (prev === node ? prev : node));\n }, []);\n\n // Derive constraints and colors from product info\n const limits = useMemo(() => {\n const info = state.productInfo;\n const computedLimits = {\n maxAdults: info?.max_mayores ?? info?.MaxAdult ?? Number.POSITIVE_INFINITY,\n maxChildren: info?.max_menores ?? info?.MaxChild ?? Number.POSITIVE_INFINITY,\n maxBabies: info?.max_babies ?? info?.MaxBabies ?? Number.POSITIVE_INFINITY,\n maxChildAge: info?.max_childrens_age ?? info?.MaxChildrensAge ?? 17,\n maxBabyAge: info?.max_babies_age ?? info?.MaxBabiesAge ?? 2,\n datePickerStart: info?.date_picker_start ?? info?.DatePickerStart ?? 0,\n };\n console.log('[booking-search-widget] computed limits from productInfo', {\n raw: {\n max_mayores: info?.max_mayores,\n MaxAdult: info?.MaxAdult,\n max_menores: info?.max_menores,\n MaxChild: info?.MaxChild,\n max_babies: info?.max_babies,\n MaxBabies: info?.MaxBabies,\n max_childrens_age: info?.max_childrens_age,\n MaxChildrensAge: info?.MaxChildrensAge,\n max_babies_age: info?.max_babies_age,\n MaxBabiesAge: info?.MaxBabiesAge,\n },\n computed: computedLimits,\n });\n return computedLimits;\n }, [state.productInfo]);\n\n const toLocalYMD = (date: Date): string => {\n const y = date.getFullYear();\n const m = String(date.getMonth() + 1).padStart(2, '0');\n const d = String(date.getDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n };\n\n const minStartDateIso = useMemo(() => {\n const base = new Date();\n base.setHours(0, 0, 0, 0);\n const iso = toLocalYMD(base);\n console.log('[booking-search-widget] computed minStartDateIso', { datePickerStart: limits.datePickerStart, iso });\n return iso;\n }, [limits.datePickerStart]);\n\n // Track previous and current active segment to animate directionally\n const [lastActiveIndex, setLastActiveIndex] = useState<number | null>(null);\n const [enterDir, setEnterDir] = useState<'initial' | 'fromLeft' | 'fromRight' | null>(null);\n\n const currentActiveIndex = useMemo(() => {\n return calendarMode === 'checkin' ? 0 : calendarMode === 'checkout' ? 1 : guestsOpen ? 2 : null;\n }, [calendarMode, guestsOpen]);\n\n useEffect(() => {\n if (currentActiveIndex !== null) {\n if (lastActiveIndex === null) {\n setEnterDir('initial');\n } else if (currentActiveIndex > lastActiveIndex) {\n // Moving to the right: animate from right to left\n setEnterDir('fromRight');\n } else if (currentActiveIndex < lastActiveIndex) {\n // Moving to the left: animate from left to right\n setEnterDir('fromLeft');\n } else {\n setEnterDir('initial');\n }\n setLastActiveIndex(currentActiveIndex);\n }\n }, [currentActiveIndex, lastActiveIndex]);\n\n const segmentClasses = (index: number): string => {\n const isActive = currentActiveIndex === index;\n const base = `${css.segment} ${isActive ? css.segmentActive : ''}`.trim();\n // Avoid translating the whole segment to prevent visible horizontal shift\n return isActive ? `${base} ${css.fadeIn}` : base;\n };\n\n const segmentBgAnimClass = (index: number): string => {\n if (currentActiveIndex !== index) return '';\n if (enterDir === 'fromRight') return css.bgFadeInFromRight;\n if (enterDir === 'fromLeft') return css.bgFadeInFromLeft;\n return css.bgFadeIn;\n };\n\n\n useEffect(() => {\n if (!hasHydrated.current) {\n hasHydrated.current = true;\n return;\n }\n setState(createInitialState(props));\n }, [\n props.initialStart,\n props.initialEnd,\n props.initialGroups,\n props.locale,\n props.currency,\n props.pos,\n props.productId,\n props.redirect,\n ]);\n\n // Observe when the main search bar is out of view to show a compact sticky button\n useEffect(() => {\n const el = searchBarRef.current;\n if (!el) return;\n\n const recompute = (visible: boolean) => {\n searchBarVisibleRef.current = visible;\n setShowStickyMini(!visible);\n if (visible && pendingOpenRef.current) {\n setCalendarMode('checkin');\n setCheckinOpen(true);\n setCheckoutOpen(false);\n setGuestsOpen(false);\n pendingOpenRef.current = false;\n }\n };\n\n const io = new IntersectionObserver(\n (entries) => {\n const entry = entries[0];\n const visible = entry.isIntersecting && entry.intersectionRatio > 0;\n recompute(visible);\n },\n { root: null, threshold: [0, 0.01, 0.5, 1] }\n );\n io.observe(el);\n\n const onScroll = () => recompute(searchBarVisibleRef.current);\n window.addEventListener('scroll', onScroll, { passive: true });\n\n return () => {\n io.disconnect();\n window.removeEventListener('scroll', onScroll);\n };\n }, []);\n\n const handleStickyMiniClick = () => {\n if (isMobile) {\n // En mobile: expandir el formulario primero, luego hacer scroll\n setIsMobileFormExpanded(true);\n pendingOpenRef.current = true;\n // Pequeño delay para que el formulario se expanda antes de hacer scroll\n setTimeout(() => {\n const targetElement = searchBarRef.current;\n if (targetElement) {\n console.log('[booking-search-widget] scrolling to search bar', targetElement);\n targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });\n } else {\n console.warn('[booking-search-widget] searchBarRef is null');\n }\n }, 100);\n } else {\n // En desktop: scroll al formulario\n pendingOpenRef.current = true;\n const targetElement = searchBarRef.current;\n if (targetElement) {\n console.log('[booking-search-widget] scrolling to search bar (desktop)', targetElement);\n targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });\n } else {\n console.warn('[booking-search-widget] searchBarRef is null (desktop)');\n }\n }\n };\n\n const emit = useCallback(\n (type: WidgetEventType, payload?: unknown) => {\n forwardEvent(props.onEvent, type, payload);\n },\n [props.onEvent]\n );\n\n useEffect(() => {\n emit('ready', { state });\n console.log('[booking-search-widget] widget ready', { state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Ensure iframe documents declare a responsive viewport\n useEffect(() => {\n if (typeof document === 'undefined' || typeof window === 'undefined') return;\n let isInIframe = false;\n try {\n isInIframe = window.self !== window.top;\n } catch {\n isInIframe = true;\n }\n if (!isInIframe) return;\n\n const existingMeta = document.querySelector('meta[name=\"viewport\"]');\n if (existingMeta) return;\n\n const meta = document.createElement('meta');\n meta.name = 'viewport';\n meta.content = 'width=device-width, initial-scale=1';\n document.head.appendChild(meta);\n\n return () => {\n if (meta.parentNode) {\n meta.parentNode.removeChild(meta);\n }\n };\n }, []);\n\n // Track viewport to toggle mobile behavior\n useEffect(() => {\n if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;\n const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);\n const onChange = () =>\n setIsViewportMobile((prev) => {\n const next = mql.matches;\n return prev === next ? prev : next;\n });\n try {\n mql.addEventListener('change', onChange);\n } catch {\n // Safari fallback\n mql.addListener(onChange);\n }\n onChange();\n return () => {\n try {\n mql.removeEventListener('change', onChange);\n } catch {\n mql.removeListener(onChange);\n }\n };\n }, []);\n\n // Track container width to handle iframe or constrained embeds\n useEffect(() => {\n if (!containerNode) return;\n if (typeof window === 'undefined') return;\n\n const updateFromContainer = (width?: number) => {\n const nextWidth = typeof width === 'number' ? width : containerNode.getBoundingClientRect().width;\n setIsContainerMobile((prev) => {\n const next = nextWidth <= MOBILE_BREAKPOINT;\n return prev === next ? prev : next;\n });\n };\n\n updateFromContainer();\n\n const ResizeObserverCtor = typeof window.ResizeObserver === 'function' ? window.ResizeObserver : undefined;\n if (ResizeObserverCtor) {\n const observer = new ResizeObserverCtor((entries) => {\n if (!entries.length) {\n updateFromContainer();\n return;\n }\n const width = entries[0].contentRect?.width;\n updateFromContainer(typeof width === 'number' ? width : undefined);\n });\n observer.observe(containerNode);\n return () => observer.disconnect();\n }\n\n const onResize = () => updateFromContainer();\n window.addEventListener('resize', onResize);\n return () => {\n window.removeEventListener('resize', onResize);\n };\n }, [containerNode]);\n\n const handleStateChange = useCallback(\n (next: Partial<WidgetState> | ((prev: WidgetState) => WidgetState)) => {\n setState((prev) => {\n const candidate = typeof next === 'function' ? (next as (prev: WidgetState) => WidgetState)(prev) : { ...prev, ...next };\n emit('change', { state: candidate });\n return candidate;\n });\n },\n [emit]\n );\n\n // Initial product info fetch (and refetch if productId/token changes)\n useEffect(() => {\n let cancelled = false;\n const run = async () => {\n try {\n const url = `https://api-1-eb.pxsol.io/product/info?id=${state.productId}&t=${\"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ill5Mi1oSy1VYktQTFZSci1QSm5heSJ9.eyJnaXZlbl9uYW1lIjoiU2FudGlhZ28iLCJmYW1pbHlfbmFtZSI6IkNhYnJhbCIsIm5pY2tuYW1lIjoic2NhYnJhbCIsIm5hbWUiOiJTYW50aWFnbyBDYWJyYWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSkp2NkxsUFVxVl90NTRobjk1TGlqWTJXcTN3S21uaDNSYjZaZFY4Tzk4MW1XOGgySVc9czk2LWMiLCJ1cGRhdGVkX2F0IjoiMjAyNS0xMS0xMFQxODowNjoyMi40NTZaIiwiZW1haWwiOiJzY2FicmFsQHB4c29sLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJpc3MiOiJodHRwczovL2F1dGgwLnB4c29sLmNvbS8iLCJhdWQiOiJtN2pDRFBhcVFCaGxNM2hRbFVZYnhhUURUVXhvSmtJVyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTA5Njc3NTgxMDcwMTk4NTU4NjI2IiwiaWF0IjoxNzYyNzk3OTgzLCJleHAiOjE3NjMzOTc5ODMsInNpZCI6IldlRVpNRURhVjFzellyWWh0cXdkbDJPaWtERUpRa190Iiwibm9uY2UiOiI3NWU1M2RkZGFhMzczNWYxMzkzODYxMTM4NGE1ZDZkNCJ9.VON_f1eYpEBwG_L3yXkz4IjeLJXdm6XHS5d753Zm5bBcvQc3zPuCyqG_4vHP5KOHK17VeWslkap1TR_yLGJNUB9XPoyTH7KjQrArPtW9P2p-cIO3uADYdv1sGsLFYYjb_KkVcSOUFD8XXbQ-3mgMOXLKqJbUiLT1M12X9v-mZv-OWwkrg0gLTW9hZVaqs3OU0tnwUsfEH3nHge5EdUj5yAGVDi31aDFke-afR2hBlFEZJ4JHuqjtWXD_vlW-C18psYoWSRojlkkP31W5A6M8TILZZm_ck1QoPf8aN6aXF_g6btkhLpuhw7PSuVmImiw4IFlxdcAnv0dYt962Kqf8HA\"}`;\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n \n console.log('[booking-search-widget][product-info] REQUEST - Iniciando solicitud', {\n url,\n productId: state.productId,\n token: `${PRODUCT_INFO_TOKEN.substring(0, 20)}...`,\n method: 'GET',\n });\n const res = await fetch(url, { headers });\n console.log('[booking-search-widget][product-info] RESPONSE STATUS', {\n status: res.status,\n statusText: res.statusText,\n ok: res.ok,\n url: res.url,\n });\n \n // Log response headers\n try {\n const headersObj: Record<string, string> = {};\n res.headers.forEach((v, k) => (headersObj[k] = v));\n console.log('[booking-search-widget][product-info] RESPONSE HEADERS', headersObj);\n } catch (err) {\n console.warn('[booking-search-widget][product-info] Error al leer headers', err);\n }\n \n if (!res.ok) {\n const errorText = await res.text().catch(() => 'No se pudo leer el cuerpo del error');\n console.error('[booking-search-widget][product-info] ERROR RESPONSE', {\n status: res.status,\n statusText: res.statusText,\n body: errorText,\n });\n throw new Error(`Product info failed ${res.status}: ${errorText}`);\n }\n \n const rawText = await res.text();\n console.log('[booking-search-widget][product-info] RESPONSE RAW BODY', {\n length: rawText.length,\n preview: rawText.substring(0, 500) + (rawText.length > 500 ? '...' : ''),\n });\n \n const parsedResponse = JSON.parse(rawText);\n console.log('[booking-search-widget][product-info] ========== RESPUESTA COMPLETA DE /product/info ==========');\n console.log('[booking-search-widget][product-info] RESPONSE COMPLETA (JSON completo)', JSON.stringify(parsedResponse, null, 2));\n console.log('[booking-search-widget][product-info] RESPONSE PARSED JSON - Estructura completa', {\n topLevelKeys: Object.keys(parsedResponse),\n hasInfo: 'info' in parsedResponse,\n response: parsedResponse.response,\n message: parsedResponse.message,\n code: parsedResponse.code,\n });\n \n // Log específico para GroupPluralText\n console.log('[booking-search-widget][product-info] VERIFICANDO GroupPluralText', {\n 'parsedResponse.GroupPluralText': parsedResponse.GroupPluralText,\n 'parsedResponse.info?.GroupPluralText': parsedResponse.info?.GroupPluralText,\n 'has GroupPluralText': 'GroupPluralText' in parsedResponse,\n 'has info.GroupPluralText': parsedResponse.info && 'GroupPluralText' in parsedResponse.info,\n });\n \n // La API puede devolver los datos dentro de 'info' o directamente\n const data = (parsedResponse.info || parsedResponse) as ProductInfo;\n console.log('[booking-search-widget][product-info] ========== DATOS EXTRAÍDOS DEL PRODUCTO ==========');\n console.log('[booking-search-widget][product-info] DATA (productInfo) - Objeto completo extraído', JSON.stringify(data, null, 2));\n console.log('[booking-search-widget][product-info] VERIFICANDO GroupPluralText EN DATA', {\n 'data.GroupPluralText': data?.GroupPluralText,\n 'data has GroupPluralText': data && 'GroupPluralText' in data,\n 'typeof data.GroupPluralText': typeof data?.GroupPluralText,\n });\n console.log('[booking-search-widget][product-info] RESPONSE - Propiedades de edad de menores', {\n 'max_childrens_age (snake_case)': data?.max_childrens_age,\n 'MaxChildrensAge (PascalCase)': data?.MaxChildrensAge,\n 'MaxChild': data?.MaxChild,\n 'max_mayores': data?.max_mayores,\n 'max_menores': data?.max_menores,\n 'MaxBabies': data?.MaxBabies,\n 'max_babies': data?.max_babies,\n 'MaxBabiesAge': data?.MaxBabiesAge,\n 'max_babies_age': data?.max_babies_age,\n 'GroupPluralText': data?.GroupPluralText,\n 'todas las keys relacionadas con child/children/age/baby': Object.keys(data || {}).filter(key => \n key.toLowerCase().includes('child') || \n key.toLowerCase().includes('children') || \n key.toLowerCase().includes('age') ||\n key.toLowerCase().includes('baby') ||\n key.toLowerCase().includes('menor') ||\n key.toLowerCase().includes('mayor')\n ),\n 'todas las keys relacionadas con room/group': Object.keys(data || {}).filter(key => \n key.toLowerCase().includes('room') || \n key.toLowerCase().includes('group')\n ),\n 'todas las keys del objeto completo': Object.keys(data || {}),\n });\n if (cancelled) return;\n handleStateChange((prev) => {\n const next = { ...prev, productInfo: data } as WidgetState;\n try {\n const now = new Date();\n now.setHours(0, 0, 0, 0);\n const datePickerStart =\n (typeof data?.date_picker_start === 'number' ? data.date_picker_start : data?.DatePickerStart) ?? 0;\n const minIso = toLocalYMD(now);\n if (next.startDate < minIso) {\n next.startDate = minIso;\n if (next.endDate <= next.startDate) {\n const d = new Date(minIso);\n d.setDate(d.getDate() + 1);\n next.endDate = toLocalYMD(d);\n }\n }\n const computedMaxChildAge = data?.max_childrens_age ?? data?.MaxChildrensAge ?? 17;\n console.log('[booking-search-widget][product-info] APPLIED - Límites aplicados al estado', {\n rawData: {\n max_mayores: data?.max_mayores,\n MaxAdult: data?.MaxAdult,\n max_menores: data?.max_menores,\n MaxChild: data?.MaxChild,\n max_babies: data?.max_babies,\n MaxBabies: data?.MaxBabies,\n max_childrens_age: data?.max_childrens_age,\n MaxChildrensAge: data?.MaxChildrensAge,\n max_babies_age: data?.max_babies_age,\n MaxBabiesAge: data?.MaxBabiesAge,\n date_picker_start: data?.date_picker_start,\n DatePickerStart: data?.DatePickerStart,\n GroupPluralText: data?.GroupPluralText,\n },\n computedLimits: {\n maxAdults: data?.max_mayores ?? data?.MaxAdult ?? Number.POSITIVE_INFINITY,\n maxChildren: data?.max_menores ?? data?.MaxChild ?? Number.POSITIVE_INFINITY,\n maxBabies: data?.max_babies ?? data?.MaxBabies ?? Number.POSITIVE_INFINITY,\n maxChildAge: computedMaxChildAge,\n maxBabyAge: data?.max_babies_age ?? data?.MaxBabiesAge ?? 2,\n datePickerStart: datePickerStart,\n },\n dates: {\n startDate: next.startDate,\n endDate: next.endDate,\n minStartDateIso,\n },\n });\n } catch (err) {\n console.warn('[booking-search-widget] product info apply error', err);\n }\n return next;\n });\n } catch (e) {\n console.error('[booking-search-widget] product info error', e);\n }\n };\n run();\n return () => {\n cancelled = true;\n };\n }, [state.productId, handleStateChange]);\n\n // POS info fetch to derive ThemeColor\n useEffect(() => {\n let cancelled = false;\n const run = async () => {\n try {\n const posId = state.pos || 'GLOBAL';\n const url = `https://api-2-eb.pxsol.io/api/pos/info/${encodeURIComponent(posId)}`;\n console.log('[booking-search-widget] fetching pos info', { url, pos: posId });\n const res = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!res.ok) throw new Error(`POS info failed ${res.status}`);\n const data = await res.json();\n if (cancelled) return;\n handleStateChange((prev) => ({ ...prev, posInfo: data }));\n console.log('[booking-search-widget] pos info json', data);\n } catch (e) {\n console.warn('[booking-search-widget] pos info error', e);\n }\n };\n run();\n return () => {\n cancelled = true;\n };\n }, [state.pos, handleStateChange]);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n const validation = validateState(state);\n if (!validation.valid) {\n handleStateChange({ errors: validation.errors });\n emit('validate_error', validation);\n return;\n }\n\n const payload = buildSearchPayload({ ...state, errors: {} }, props);\n console.log('[booking-search-widget][search] ========== REQUEST A /v2/search ==========');\n console.log('[booking-search-widget][search] REQUEST URL', 'https://gateway-prod.pxsol.com/v2/search');\n console.log('[booking-search-widget][search] REQUEST METHOD', 'POST');\n console.log('[booking-search-widget][search] REQUEST HEADERS', {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${props.token ? `${props.token.substring(0, 20)}...` : 'null'}`,\n });\n console.log('[booking-search-widget][search] REQUEST PAYLOAD (JSON)', JSON.stringify(payload, null, 2));\n console.log('[booking-search-widget][search] REQUEST PAYLOAD (objeto)', payload);\n handleStateChange({ isSearching: true, errors: {} });\n emit('search_start', payload);\n\n try {\n const response = await fetch('https://gateway-prod.pxsol.com/v2/search', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${props.token}`,\n },\n body: JSON.stringify(payload),\n });\n console.log('[booking-search-widget][search] response meta', {\n ok: response.ok,\n status: response.status,\n statusText: response.statusText,\n url: response.url,\n redirected: response.redirected,\n type: response.type,\n });\n try {\n const headersObj: Record<string, string> = {};\n response.headers.forEach((v, k) => (headersObj[k] = v));\n console.log('[booking-search-widget][search] response headers', headersObj);\n } catch {}\n\n const clone = response.clone();\n const rawText = await clone.text().catch(() => '(unable to read body as text)');\n console.log('[booking-search-widget][search] raw body', rawText);\n\n if (!response.ok) {\n throw new Error(`Search failed with status ${response.status}`);\n }\n\n const data = (await response.json()) as SearchResponse;\n console.log('[booking-search-widget][search] parsed json', data);\n // Keep isSearching true until after redirect\n handleStateChange({ lastResponse: data });\n emit('search_success', { request: payload, response: data });\n\n if (data.booking_engine_url) {\n console.log('[booking-search-widget] redirecting to booking engine', data.booking_engine_url);\n window.location.assign(data.booking_engine_url);\n } else {\n // Only clear loading if no redirect URL is provided\n handleStateChange({ isSearching: false });\n }\n } catch (error) {\n const err = error instanceof Error ? error : new Error('Unexpected error');\n handleStateChange({ isSearching: false });\n emit('search_error', { request: payload, error: err });\n console.error('[booking-search-widget][search] error', err);\n }\n };\n\n const groupsSummary = useMemo(() => summarizeGuests(state.groups), [state.groups]);\n\n const errorMessage = Object.values(state.errors)[0];\n\n // Calculate number of nights\n const numberOfNights = useMemo(() => {\n if (!state.startDate || !state.endDate) return 0;\n const start = new Date(state.startDate);\n const end = new Date(state.endDate);\n const diffTime = end.getTime() - start.getTime();\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n return Math.max(1, diffDays);\n }, [state.startDate, state.endDate]);\n\n // Night adjustment handlers\n const adjustNights = useCallback((delta: number) => {\n setState((prevState) => {\n if (!prevState.startDate || !prevState.endDate) return prevState;\n \n // Parse dates properly using local timezone\n const [startY, startM, startD] = prevState.startDate.split('-').map(Number);\n const startDate = new Date(startY, startM - 1, startD);\n \n // Calculate current nights\n const [endY, endM, endD] = prevState.endDate.split('-').map(Number);\n const endDate = new Date(endY, endM - 1, endD);\n const diffTime = endDate.getTime() - startDate.getTime();\n const currentNights = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n \n // Calculate new nights\n const newNights = Math.max(1, currentNights + delta);\n \n // Create new end date by adding newNights to start date\n const newEndDate = new Date(startDate);\n newEndDate.setDate(startDate.getDate() + newNights);\n \n // Format the date\n const newEndY = newEndDate.getFullYear();\n const newEndM = String(newEndDate.ge