BNO Travel
BNO Travel Planifica tu viaje más fácilmente en la aplicación
Abrir

Planifica tu viaje más fácilmente con BNO Travel

Encuentra alojamiento en una selección de destinos, busca vuelos para viajes cercanos o lejanos y reserva un coche de alquiler para mantener la libertad de movimiento. Todo reunido en una solución clara y fácil de usar.

Decisiones más sencillas. Una mejor visión de conjunto.

BNO Travel reúne los elementos esenciales de tu viaje en una sola solución, adaptada tanto a las necesidades actuales como a su evolución futura.

Encuentra tu próximo destino

Desde las montañas noruegas y los Alpes hasta el sol, las grandes ciudades y los viajes lejanos. Descubre una selección de destinos, encuentra alojamiento y sigue planificando tu viaje con vuelos y coche de alquiler.

Destinos destacados

Destinos en los que BNO Travel ofrece una selección especialmente amplia de alojamientos, desde Escandinavia y los Alpes hasta el Mediterráneo y Canadá.

Montaña, esquí y naturaleza durante todo el año

Desde las montañas noruegas y suecas hasta los Alpes. Destinos para esquiar, montar en bicicleta, hacer senderismo, disfrutar de vacaciones en familia y vivir la naturaleza durante todo el año.

Trysil

Trysil

Esquí, bicicleta, montaña y actividades para toda la familia durante todo el año.

Descubre Trysil
Sälen

Sälen

Vacaciones de montaña en Suecia con esquí, actividades y experiencias en familia.

Descubre Sälen
Vemdalen

Vemdalen

Montaña, esquí y naturaleza en una de las zonas de montaña más populares de Suecia.

Descubre Vemdalen
Disponible ahora Geilo

Geilo

Un destino clásico de montaña en Noruega con esquí, esquí de fondo, senderismo y actividades durante todo el año.

Explorar Geilo
Disponible ahora Hafjell

Hafjell

Esquí alpino, ciclismo y actividades familiares en uno de los destinos de montaña más conocidos de Noruega.

Explorar Hafjell

Sol y playa

Mediterráneo, vida isleña y destinos cálidos para vacaciones de playa, estancias en familia y días de descanso.

Disponible ahora Corfú

Corfú

Vida en una isla griega con aguas cristalinas, playas y pequeños pueblos.

Explorar Corfú
Disponible ahora Sarandë y Ksamil, Albania

Sarandë & Ksamil

La costa sur de Albania, con aguas cristalinas, pequeñas calas y un ambiente de vacaciones relajado.

Explorar Sarandë & Ksamil
Disponible ahora Atenas y la Riviera ateniense

Atenas y la Riviera ateniense

Combina la vida urbana, la gastronomía griega y la historia con playas y vida costera a lo largo de la Riviera ateniense.

Explorar Atenas y la Riviera ateniense
Disponible ahora Dubai

Dubai

Sol, playa, gran ciudad moderna, restaurantes, compras y experiencias durante todo el año.

Explorar Dubai
Próximamente México

México

Playas, cultura, gastronomía y experiencias en uno de los destinos vacacionales más variados del mundo.

Próximamente

Grandes ciudades

Cultura, compras, restaurantes y experiencias en ciudades populares de Europa y del resto del mundo.

Disponible ahora Atenas

Atenas

Historia, gastronomía griega, vida urbana y fácil acceso a la costa y las islas.

Explorar Atenas
Disponible ahora London

London

Compras, cultura, fútbol, restaurantes y experiencias urbanas clásicas.

Explorar London
Disponible ahora Paris

Paris

Gastronomía, arquitectura, compras y cultura de fama mundial.

Explorar Paris
Disponible ahora New York

New York

Compras, restaurantes, cultura y experiencias urbanas icónicas.

Explorar New York
Disponible ahora Los Angeles

Los Angeles

Gran ciudad, playas, entretenimiento y experiencias icónicas en California.

Explorar Los Angeles
Disponible ahora Miami

Miami

Vida de playa, energía urbana, compras, restaurantes y ambiente tropical de Florida.

Explorar Miami

Descubre Noruega

Desde montañas y fiordos hasta grandes ciudades, auroras boreales y experiencias árticas.

Sunnmørsalpene

Sunnmørsalpene

Fiordos, montañas y espectaculares paisajes del oeste de Noruega alrededor de Stranda.

Descubre Sunnmørsalpene
Disponible ahora Oslo

Oslo

Cultura, restaurantes, compras y experiencias en el fiordo.

Explorar Oslo
Disponible ahora Bergen

Bergen

Bryggen, montañas y un punto de partida natural para descubrir los fiordos.

Explorar Bergen
Disponible ahora Trondheim

Trondheim

Historia, experiencias gastronómicas, la catedral de Nidaros y una animada vida urbana durante todo el año.

Explorar Trondheim
Disponible ahora Tromsø

Tromsø

Auroras boreales, sol de medianoche, montañas y vida urbana ártica.

Explorar Tromsø

Viajes lejanos y el mundo

Viaja más lejos y descubre grandes ciudades, destinos tropicales, Norteamérica y Asia.

Disponible ahora Ontário, Canadá

Ontario, Canadá

Lagos, naturaleza y pequeñas ciudades, con Toronto al alcance.

Descubre Ontario
Disponible ahora Dubai

Dubai

Playa, gran ciudad, compras, restaurantes y experiencias bajo el sol.

Explorar Dubai
Disponible ahora Bangkok

Bangkok

Gastronomía, cultura, compras y vida urbana, además de un punto de partida natural para seguir viajando por Asia.

Explorar Bangkok
Disponible ahora New York

New York

Una de las ciudades más conocidas del mundo por su cultura, gastronomía, compras y experiencias.

Explorar New York
Disponible ahora Los Angeles

Los Angeles

California, playas, gran ciudad y experiencias icónicas.

Explorar Los Angeles
Próximamente México

México

Playas, historia, cultura gastronómica y destinos llenos de color.

Próximamente

Encuentra más destinos

Nuestra selección crece rápidamente. Consulta todos los alojamientos disponibles y encuentra el destino que mejor encaja con tu viaje.

Ver todos los destinos
🏡

Alojamientos en una selección de destinos

Descubre una cuidada selección de alojamientos en regiones y destinos populares, adecuados tanto para estancias cortas como para vacaciones prolongadas.

Ver los alojamientos
✈️

Vuelos adaptados a tu viaje

Busca vuelos a destinos nacionales e internacionales y encuentra las opciones que encajen con tus planes de viaje.

Buscar un vuelo
🚗

Coche de alquiler para viajar con más libertad

Encuentra un coche de alquiler para continuar fácilmente tu viaje, tanto si te diriges a una ciudad como a la montaña, la costa u otro destino.

Encontrar un coche de alquiler

Una forma más clara y flexible de planificar tu viaje

BNO Travel está diseñado para simplificar la planificación de tu viaje. Con accesos claros a alojamientos, vuelos y coches de alquiler, disfrutas de una experiencia más fácil de entender, tanto si quieres reservar hoy como explorar posibilidades para un próximo viaje.

Más información sobre BNO Travel
Opciones claras y navegación sencilla
Alojamientos en una selección de destinos
Vuelos y coches de alquiler para ampliar tus opciones de viaje
Una solución diseñada para evolucionar

Aplicación BNO Travel – Mantén una mejor visión de conjunto de tu viaje, también cuando estés en movimiento

BNO Travel está diseñado para funcionar igual de bien en la web y en la aplicación. Empieza a planificar donde te resulte más cómodo y utiliza la misma solución para mantener el control sobre el resto del viaje.

  • Planifica fácilmente desde móvil y ordenador
  • Disfruta de una experiencia de viaje más integrada
  • Sigue tu viaje en una solución diseñada para evolucionar con tus necesidades
Aplicación BNO Travel

Alquila tu alojamiento con BNO Travel

¿Tienes un chalet, un apartamento u otro alojamiento en una región atractiva? BNO Travel permite a propietarios y arrendadores incorporarse a una plataforma de viajes profesional centrada en la calidad de la presentación, la visibilidad y una experiencia clara para los viajeros.

Permitimos que los alojamientos de destinos seleccionados se presenten de forma profesional y fiable como parte de una experiencia de viaje global. El objetivo es facilitar el encuentro con los viajeros adecuados y dar mayor valor a tu oferta en un entorno digital más eficaz.

Alquila tu alojamiento con BNO Travel
' ); scrollBottom(); } function appendAssistantMessage(text, label) { const safeLabel = String(label || 'Asistente de Viaje BNO con IA').trim(); chatEl.insertAdjacentHTML('beforeend', '
' + (safeLabel ? '
' + escapeHtml(safeLabel) + '
' : '') + '

' + escapeHtml(text).replace(/\n/g, '
') + '

' ); scrollBottom(); } function appendAssistantRichMessage(html, label) { if (!html) return; const safeLabel = String(label || 'Asistente de Viaje BNO con IA').trim(); chatEl.insertAdjacentHTML('beforeend', '
' + (safeLabel ? '
' + escapeHtml(safeLabel) + '
' : '') + html + '
' ); scrollBottom(); } function cleanHttpUrl(value) { const raw = String(value || '').trim(); return /^https?:\/\//i.test(raw) ? raw : ''; } function firstImageUrl() { for (let i = 0; i < arguments.length; i += 1) { const value = arguments[i]; if (Array.isArray(value)) { for (let j = 0; j < value.length; j += 1) { const nested = firstImageUrl(value[j]); if (nested) return nested; } continue; } if (value && typeof value === 'object') { const nestedObjectUrl = firstImageUrl( value.url, value.Url, value.src, value.Src, value.image, value.Image, value.imageUrl, value.ImageUrl ); if (nestedObjectUrl) return nestedObjectUrl; continue; } const url = cleanHttpUrl(value); if (url) return url; } return ''; } function richImageHtml(url, altText) { const safeUrl = cleanHttpUrl(url); if (!safeUrl) return ''; return '
' + '' + escapeHtml(altText || '') + '' + '
'; } function numberValue(value) { if (value === null || value === undefined || value === '') return null; const n = Number(value); return Number.isFinite(n) ? n : null; } function formatMoney(amount, currency) { const n = positiveMoneyValue(amount); if (n === null) return ''; const code = String(currency || '').trim().toUpperCase(); try { return new Intl.NumberFormat(getHelperLocale(), { maximumFractionDigits: 0 }).format(n) + (code ? ' ' + code : ''); } catch (error) { return String(n) + (code ? ' ' + code : ''); } } function firstText() { for (let i = 0; i < arguments.length; i += 1) { const value = arguments[i]; if (value !== null && value !== undefined && String(value).trim()) return String(value).trim(); } return ''; } function flightSummary(flight) { if (!flight || typeof flight !== 'object') return null; const slices = Array.isArray(flight.slices) ? flight.slices : []; const outbound = slices[0] || null; const segments = outbound && Array.isArray(outbound.segments) ? outbound.segments : []; const first = segments[0] || null; const last = segments[segments.length - 1] || null; const owner = flight.owner || {}; return { airline: firstText(owner.name, first && first.marketing_carrier && first.marketing_carrier.name, first && first.operating_carrier && first.operating_carrier.name), route: first && last ? firstText(first.origin && (first.origin.city_name || first.origin.iata_code)) + ' → ' + firstText(last.destination && (last.destination.city_name || last.destination.iata_code)) : '', price: formatMoney(flight.total_with_fee !== undefined ? flight.total_with_fee : flight.total_amount, flight.total_currency) }; } function appendRichHtml(html) { if (!html) return; chatEl.insertAdjacentHTML('beforeend', '
' + html + '
'); scrollBottom(); } function stripRichText(value) { const raw = String(value || '').trim(); if (!raw) return ''; try { const holder = document.createElement('div'); holder.innerHTML = raw; return String(holder.textContent || holder.innerText || '') .replace(/\s+/g, ' ') .trim(); } catch (error) { return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } } function shortenText(value, maxLength) { const clean = stripRichText(value); const limit = Math.max(40, Number(maxLength || 260)); if (!clean || clean.length <= limit) return clean; const cut = clean.slice(0, limit + 1); const lastSpace = cut.lastIndexOf(' '); return (lastSpace > Math.floor(limit * 0.65) ? cut.slice(0, lastSpace) : cut.slice(0, limit)).trim() + '…'; } function firstFiniteNumber() { for (let i = 0; i < arguments.length; i += 1) { const value = arguments[i]; if (value === null || value === undefined || value === '') continue; const n = Number(value); if (Number.isFinite(n) && n >= 0) return n; } return null; } function getPackageRoom(option) { const accommodationAction = option && option.accommodationAction || {}; const checkoutAccommodation = option && option.checkoutPayload && option.checkoutPayload.accommodation || {}; return accommodationAction.room || checkoutAccommodation.room || {}; } function getPackageAccommodation(option) { return option && option.checkoutPayload && option.checkoutPayload.accommodation || {}; } function getPackageFlight(option) { const checkoutFlight = option && option.checkoutPayload && option.checkoutPayload.flight || {}; const flightAction = option && option.flightAction || {}; return checkoutFlight.offer || flightAction.offer || null; } function getPackageFlightSearch(option) { const checkoutFlight = option && option.checkoutPayload && option.checkoutPayload.flight || {}; const flightAction = option && option.flightAction || {}; return checkoutFlight.search || flightAction.search || {}; } function formatFlightClock(value) { const raw = String(value || ''); const match = raw.match(/T(\d{2}):(\d{2})/); return match ? match[1] + ':' + match[2] : ''; } function flightPointLabel(point) { if (!point || typeof point !== 'object') return ''; const city = firstText(point.city_name, point.cityName, point.name); const code = firstText(point.iata_code, point.iataCode).toUpperCase(); if (city && code && normalizeIntentText(city) !== normalizeIntentText(code)) return city + ' (' + code + ')'; return city || code; } function flightLegDetails(slice) { if (!slice || typeof slice !== 'object') return null; const segments = Array.isArray(slice.segments) ? slice.segments : []; const first = segments[0] || null; const last = segments[segments.length - 1] || null; if (!first || !last) return null; const departingAt = firstText(first.departing_at, slice.departing_at); const arrivingAt = firstText(last.arriving_at, slice.arriving_at); const departureDate = departingAt.slice(0, 10); const arrivalDate = arrivingAt.slice(0, 10); return { origin: flightPointLabel(first.origin || slice.origin), destination: flightPointLabel(last.destination || slice.destination), departureTime: formatFlightClock(departingAt), arrivalTime: formatFlightClock(arrivingAt), arrivesNextDay: Boolean(departureDate && arrivalDate && departureDate !== arrivalDate), stops: Math.max(0, segments.length - 1) }; } function formatFlightLegSentence(leg, isReturn) { if (!leg) return ''; const templateKey = isReturn ? 'returnTemplate' : 'outboundTemplate'; let sentence = tx(templateKey, { origin: leg.origin || '', dep: leg.departureTime || '', destination: leg.destination || '', arr: leg.arrivalTime || '' }).replace(/\s+/g, ' ').trim(); if (leg.arrivesNextDay) sentence += tx('nextDay'); sentence = sentence.replace(/[.。]?$/, '') + '. '; if (leg.stops === 0) sentence += tx('directFlight'); else if (leg.stops === 1) sentence += tx('oneStop'); else if (leg.stops > 1) sentence += tx('manyStops', { count: leg.stops }); return sentence.trim(); } function accommodationFactSentence(option) { const room = getPackageRoom(option); const name = firstText(option && option.packageSummary && option.packageSummary.accommodationName, room.Name, room.name); const capacity = firstFiniteNumber(room.Capacity, room.capacity, room.TotalOccupancy, room.totalOccupancy); const bedrooms = firstFiniteNumber(room.Bedrooms, room.bedrooms, room.BedroomCount, room.bedroomCount, room.NumberOfBedrooms, room.numberOfBedrooms); const bathrooms = firstFiniteNumber(room.Bathrooms, room.bathrooms, room.BathroomCount, room.bathroomCount, room.NumberOfBathrooms, room.numberOfBathrooms); const facts = []; if (capacity !== null && capacity > 0) facts.push(tx('capacityFact', { count: capacity })); if (bedrooms !== null && bedrooms > 0) facts.push(tx('bedroomFact', { count: bedrooms })); if (bathrooms !== null && bathrooms > 0) facts.push(tx('bathroomFact', { count: bathrooms })); const description = shortenText(firstText(room.Description, room.description), 260); let sentence = ''; if (facts.length) sentence = tx(name ? 'stayFacts' : 'genericFacts', { name: name, facts: facts.join(', ') }); if (description) { if (sentence) sentence += ' '; sentence += description; if (!/[.!?…。]$/.test(sentence)) sentence += '.'; } return sentence; } function shouldRecommendRentalCar(option) { const room = getPackageRoom(option); const accommodation = getPackageAccommodation(option); const summary = option && option.packageSummary || {}; const text = normalizeIntentText([ summary.accommodationArea, accommodation.area, accommodation.areaKey, room.ServiceName, room.serviceName ].filter(Boolean).join(' ')); if (text.includes('mallorca')) return 'Mallorca'; if (text.includes('costa brava')) return 'Costa Brava'; return ''; } function buildPackageNarrativeHtml(packageOptions) { if (!Array.isArray(packageOptions) || !packageOptions.length) return ''; const visibleCount = Math.min(3, packageOptions.length); const best = packageOptions[0] || {}; const room = getPackageRoom(best); const summary = best.packageSummary || {}; const flight = getPackageFlight(best); const slices = flight && Array.isArray(flight.slices) ? flight.slices : []; const airline = firstText(summary.flightAirline, flight && flight.owner && flight.owner.name, slices[0] && slices[0].segments && slices[0].segments[0] && slices[0].segments[0].marketing_carrier && slices[0].segments[0].marketing_carrier.name); const accommodationName = firstText(summary.accommodationName, room.Name, room.name, tx('accommodation')); const destinationRaw = firstText(summary.accommodationArea, getPackageAccommodation(best).area); const destination = destinationRaw.replace(/^Emerald\s+Stay\s*[-–—:]\s*/i, '').replace(/^BNO\s+Travel\s*[-–—:]\s*/i, '').trim(); const outbound = flightLegDetails(slices[0]); const inbound = flightLegDetails(slices[1]); const accommodationFacts = accommodationFactSentence(best); const rentalDestination = shouldRecommendRentalCar(best); const paragraphs = []; if (packageOptions.length === 1) paragraphs.push(tx('foundOne')); else if (packageOptions.length <= 3) paragraphs.push(tx('foundCount', { count: packageOptions.length })); else paragraphs.push(tx('foundMore', { count: visibleCount })); let bestSentence = tx('bestBase', { stay: '' + escapeHtml(accommodationName) + '' }); if (airline) bestSentence += tx('combinedWith', { airline: '' + escapeHtml(airline) + '' }); if (destination) bestSentence += tx('toDestination', { destination: escapeHtml(destination) }); bestSentence += '.'; paragraphs.push(bestSentence); const outboundSentence = formatFlightLegSentence(outbound, false); const inboundSentence = formatFlightLegSentence(inbound, true); if (outboundSentence) paragraphs.push(escapeHtml(outboundSentence)); if (inboundSentence) paragraphs.push(escapeHtml(inboundSentence)); if (accommodationFacts) paragraphs.push('' + escapeHtml(tx('shortStay')) + ' ' + escapeHtml(accommodationFacts)); if (rentalDestination) paragraphs.push(escapeHtml(tx('rentalCar', { destination: rentalDestination }))); paragraphs.push('' + escapeHtml(tx('recheck')) + ''); return '
' + paragraphs.map(function (paragraph) { return '

' + paragraph + '

'; }).join('') + '
'; } function normalizeBookingAreaKey(value) { return String(value || '') .trim() .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/æ/g, 'ae') .replace(/ø/g, 'o') .replace(/å/g, 'a') .replace(/_/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-'); } function resolvePackageBookingTarget(option) { const accommodation = getPackageAccommodation(option); const room = getPackageRoom(option); const summary = option && option.packageSummary || {}; const serviceId = String(firstText(accommodation.serviceId, room.ServiceId, room.serviceId)).trim().toLowerCase(); const explicitPartner = String(firstText(accommodation.partnerKey, room.partnerKey, room.credsKey, room.CredsKey)).trim().toUpperCase(); const rawArea = firstText(accommodation.areaKey, accommodation.area, summary.accommodationArea, room.ServiceName, room.serviceName); const area = normalizeBookingAreaKey(rawArea); const lookup = normalizeIntentText([ rawArea, room.Name, room.name, room.ServiceName, room.serviceName, room.Description, room.description ].filter(Boolean).join(' ')); const directByService = { 'a3394d26-63df-4f01-987e-ad3400cb4c9e': ['EMERALD_3_VALLEES', '3-vallees'], 'c7c0ce0f-ea30-42ac-9386-abf4013be2c1': ['EMERALD_ALPE_D_HUEZ', 'alpe-d-huez'], '29b058bd-c9ec-4681-aec6-aab800c1ae36': ['EMERALD_CHAMONIX', 'chamonix'], 'f1850807-897b-44db-b7ca-b229010044f1': ['EMERALD_CHAMPAGNY_EN_VANOISE', 'champagny-en-vanoise'], 'f481f0c4-eca9-453a-a7ba-b21000e4acd0': ['EMERALD_CHATEL_PORTES_DU_SOLEIL', 'chatel-portes-du-soleil'], '640c91fc-8d00-429e-85eb-b0a5008b8b8e': ['EMERALD_GRAND_MASSIF', 'grand-massif'], 'd895c4c5-f9f7-40cd-8e36-ac8000f9dfb7': ['EMERALD_PAYS_DU_MONT_BLANC', 'pays-du-mont-blanc'], '02cb0384-861e-486f-8b52-b0a50089eefd': ['EMERALD_VERBIER', 'verbier'], '7cb1382d-ec46-4719-9919-ad3400c91cea': ['EMERALD_COSTA_BRAVA', 'costa-brava'], 'b40b3c57-a945-4998-8d9f-ae8500e281fd': ['EMERALD_MALLORCA', 'mallorca'], 'e7a52fec-74b4-4f75-af18-b36800c3d81c': ['EMERALD_TIGNES_VAL_D_ISERE', 'tignes-val-disere'], '8dfb9109-42ac-4413-b193-1a5664b3a215': ['EMERALD_PORTES_DU_SOLEIL', 'portes-du-soleil'], '24477039-0c00-42b1-80bc-afe700d74b58': ['ALPIN_FAMILY_AVENIDA_PANORAMA_SUITES', 'kaprun-avenida-panorama-suites'], 'e263f6f4-651a-4ed8-b3c2-b26700598970': ['ELMHIRST_RESORT', 'elmhirst-resort'], '11f4b043-fefc-496f-9680-b15a007eea68': ['STRANDA', 'stranda'], '76630f0a-4a54-412f-a693-b455005db08a': ['BNO_TRYSIL', 'vemdalen'], '2d94a493-81f2-4651-9faf-b3bb0097bdeb': ['BNO_TRYSIL', 'trysil-sentrum'], '9f600b99-057d-4a35-90a4-b30600bd0c64': ['BNO_TRYSIL', 'trysil-turistsenter'], 'ba0090d5-a22d-4836-a5de-b3140084990e': ['BNO_TRYSIL', 'trysilfjell-hytteomrade'], 'c4964b1d-2303-4ebd-9be8-b3140084d802': ['BNO_TRYSIL', 'trysil-hoyfjellssenter'] }; if (serviceId && directByService[serviceId]) { return { partnerKey: directByService[serviceId][0], areaKey: directByService[serviceId][1] }; } const areaMappings = [ ['mallorca', 'EMERALD_MALLORCA', 'mallorca'], ['costa-brava', 'EMERALD_COSTA_BRAVA', 'costa-brava'], ['3-vallees', 'EMERALD_3_VALLEES', '3-vallees'], ['alpe-d-huez', 'EMERALD_ALPE_D_HUEZ', 'alpe-d-huez'], ['chamonix', 'EMERALD_CHAMONIX', 'chamonix'], ['champagny-en-vanoise', 'EMERALD_CHAMPAGNY_EN_VANOISE', 'champagny-en-vanoise'], ['chatel-portes-du-soleil', 'EMERALD_CHATEL_PORTES_DU_SOLEIL', 'chatel-portes-du-soleil'], ['grand-massif', 'EMERALD_GRAND_MASSIF', 'grand-massif'], ['pays-du-mont-blanc', 'EMERALD_PAYS_DU_MONT_BLANC', 'pays-du-mont-blanc'], ['verbier', 'EMERALD_VERBIER', 'verbier'], ['tignes-val-disere', 'EMERALD_TIGNES_VAL_D_ISERE', 'tignes-val-disere'], ['emerald-tignes-val-d-isere', 'EMERALD_TIGNES_VAL_D_ISERE', 'tignes-val-disere'], ['portes-du-soleil', 'EMERALD_PORTES_DU_SOLEIL', 'portes-du-soleil'], ['kaprun-avenida-panorama-suites', 'ALPIN_FAMILY_AVENIDA_PANORAMA_SUITES', 'kaprun-avenida-panorama-suites'], ['kaprun', 'ALPIN_FAMILY_AVENIDA_PANORAMA_SUITES', 'kaprun-avenida-panorama-suites'], ['elmhirst-resort', 'ELMHIRST_RESORT', 'elmhirst-resort'], ['sunnmorsalpene', 'STRANDA', 'stranda'], ['strandafjellet', 'STRANDA', 'stranda'], ['fjellsaetra', 'STRANDA', 'stranda'], ['hornindal', 'STRANDA', 'stranda'], ['stranda', 'STRANDA', 'stranda'], ['vemdalen', 'BNO_TRYSIL', 'vemdalen'] ]; for (let i = 0; i < areaMappings.length; i += 1) { const mapping = areaMappings[i]; if (area === mapping[0] || area.includes(mapping[0]) || lookup.includes(mapping[0].replace(/-/g, ' '))) { return { partnerKey: mapping[1], areaKey: mapping[2] }; } } if (area.startsWith('trysil-')) return { partnerKey: 'BNO_TRYSIL', areaKey: area }; if (lookup.includes('vestsidevegen')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysil-sentrum' }; if (lookup.includes('litunet')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysil-turistsenter' }; if (lookup.includes('ugla')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysilfjell-hytteomrade' }; if (lookup.includes('trysilbua') || lookup.includes('fagerhoy') || lookup.includes('hoyfjell')) { return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysil-hoyfjellssenter' }; } if (lookup.includes('lindvallen')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'lindvallen-salen' }; if (lookup.includes('tandadalen')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'tandadalen-salen' }; if (lookup.includes('hogfjallet')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'hogfjallet-salen' }; if (explicitPartner && explicitPartner !== 'DEFAULT' && area) { return { partnerKey: explicitPartner, areaKey: area }; } return null; } function compactFlightPoint(point) { if (!point || typeof point !== 'object') return null; return { iata_code: firstText(point.iata_code, point.iataCode) || undefined, city_name: firstText(point.city_name, point.cityName) || undefined, name: firstText(point.name) || undefined }; } function compactFlightCarrier(carrier) { if (!carrier || typeof carrier !== 'object') return null; return { name: firstText(carrier.name) || undefined, iata_code: firstText(carrier.iata_code, carrier.iataCode) || undefined }; } function compactFlightOffer(offer) { if (!offer || typeof offer !== 'object' || !offer.id) return null; return { id: offer.id, total_amount: offer.total_amount, total_currency: offer.total_currency, total_with_fee: offer.total_with_fee, service_fee_amount: offer.service_fee_amount, fare_brand_name: offer.fare_brand_name, brand_name: offer.brand_name, fare_name: offer.fare_name, fare_brand: offer.fare_brand, fare_family: offer.fare_family, owner: compactFlightCarrier(offer.owner), passengers: Array.isArray(offer.passengers) ? offer.passengers.map(function (passenger) { return { id: passenger && passenger.id, passenger_id: passenger && passenger.passenger_id, type: passenger && passenger.type }; }).filter(function (passenger) { return passenger.id || passenger.passenger_id; }) : [], slices: Array.isArray(offer.slices) ? offer.slices.map(function (slice) { return { id: slice && slice.id, origin: compactFlightPoint(slice && slice.origin), destination: compactFlightPoint(slice && slice.destination), segments: slice && Array.isArray(slice.segments) ? slice.segments.map(function (segment) { return { id: segment && segment.id, departing_at: segment && segment.departing_at, arriving_at: segment && segment.arriving_at, duration: segment && segment.duration, origin: compactFlightPoint(segment && segment.origin), destination: compactFlightPoint(segment && segment.destination), marketing_carrier: compactFlightCarrier(segment && segment.marketing_carrier), operating_carrier: compactFlightCarrier(segment && segment.operating_carrier) }; }) : [] }; }) : [] }; } function buildTravelHelperCheckoutPayload(option, target) { const accommodation = getPackageAccommodation(option); const room = getPackageRoom(option); const checkoutFlight = option && option.checkoutPayload && option.checkoutPayload.flight || {}; const flight = getPackageFlight(option); const flightSearch = getPackageFlightSearch(option); return { accommodation: { partnerKey: target.partnerKey, areaKey: target.areaKey, roomId: firstText(accommodation.roomId, room.RoomCategoryId, room.ResourceCategoryId) || null, categoryId: firstText(accommodation.categoryId, room.ResourceCategoryId, room.RoomCategoryId) || null, serviceId: firstText(accommodation.serviceId, room.ServiceId, room.serviceId) || null, area: firstText(accommodation.area, target.areaKey) || null, from: firstText(accommodation.from) || null, to: firstText(accommodation.to) || null, adults: firstFiniteNumber(accommodation.adults), lang: firstText(accommodation.lang, getLanguage()) || null, room: { ResourceCategoryId: firstText(room.ResourceCategoryId, room.RoomCategoryId, accommodation.categoryId, accommodation.roomId) || null, RoomCategoryId: firstText(room.RoomCategoryId, room.ResourceCategoryId, accommodation.roomId, accommodation.categoryId) || null, Name: firstText(room.Name, room.name, option && option.packageSummary && option.packageSummary.accommodationName) || null, Description: firstText(room.Description, room.description) || null, Capacity: firstFiniteNumber(room.Capacity, room.capacity, room.TotalOccupancy, room.totalOccupancy), Image: firstImageUrl(room.Image, room.image, option && option.packageSummary && option.packageSummary.accommodationImage) || null, Images: Array.isArray(room.Images) ? room.Images : (Array.isArray(room.images) ? room.images : []), PriceTotal: room.PriceTotal !== undefined ? room.PriceTotal : room.priceTotal, PriceCurrency: firstText(room.PriceCurrency, room.priceCurrency) || null, ServiceId: firstText(room.ServiceId, room.serviceId, accommodation.serviceId) || null, ServiceName: firstText(room.ServiceName, room.serviceName, option && option.packageSummary && option.packageSummary.accommodationArea) || null, credsKey: firstText(room.credsKey, room.CredsKey, accommodation.partnerKey, target.partnerKey) || null } }, flight: flight && flight.id ? { offerId: firstText(checkoutFlight.offerId, flight.id) || null, offer: compactFlightOffer(flight), search: flightSearch } : null }; } function buildPackageCheckoutUrl(option) { if (!option) return ''; const target = resolvePackageBookingTarget(option); if (!target) return ''; const accommodation = getPackageAccommodation(option); const room = getPackageRoom(option); const resourceCategoryId = firstText( accommodation.categoryId, accommodation.roomId, room.ResourceCategoryId, room.RoomCategoryId ); const from = firstText(accommodation.from, option && option.accommodationAction && option.accommodationAction.search && option.accommodationAction.search.from); const to = firstText(accommodation.to, option && option.accommodationAction && option.accommodationAction.search && option.accommodationAction.search.to); const adults = firstFiniteNumber(accommodation.adults, option && option.accommodationAction && option.accommodationAction.search && option.accommodationAction.search.adults) || 2; const propertyName = firstText( option && option.packageSummary && option.packageSummary.accommodationName, room.Name, room.name ); const imageUrl = firstImageUrl( option && option.packageSummary && option.packageSummary.accommodationImage, option && option.packageSummary && option.packageSummary.accommodationImages, room.Image, room.image, room.Images, room.images ); if (!resourceCategoryId || !from || !to) return ''; const params = new URLSearchParams(); params.set('partnerKey', target.partnerKey); params.set('areaKey', target.areaKey); params.set('resourceCategoryId', resourceCategoryId); params.set('from', from); params.set('to', to); params.set('adults', String(Math.max(1, adults))); if (propertyName) params.set('propertyName', propertyName); if (imageUrl) params.set('imageUrl', imageUrl); params.set('lang', firstText(accommodation.lang, getLanguage()) || 'nb'); params.set('source', 'travel-helper-web'); const payload = buildTravelHelperCheckoutPayload(option, target); if (payload && payload.flight && payload.flight.offerId) { params.set('travelHelperPackagePayload', JSON.stringify(payload)); } return BOOKING_ENGINE_BASE + '/booking/complete?' + params.toString(); } function extractPackageChoiceIndex(message) { if (!lastPackageOptions.length) return null; const value = normalizeIntentText(message); const numeric = value.match(/(?:velg|velger|ta|tar|onsker|vil ha|alternativ|option|nummer|nr)\s*(?:alternativ\s*)?(\d+)/); if (numeric && numeric[1]) return Number(numeric[1]); if (/\b(forste|første)\b/.test(value) && /\b(velg|velger|ta|tar|alternativ|option|vil ha)\b/.test(value)) return 1; if (/\bandre\b/.test(value) && /\b(velg|velger|ta|tar|alternativ|option|vil ha)\b/.test(value)) return 2; if (/\btredje\b/.test(value) && /\b(velg|velger|ta|tar|alternativ|option|vil ha)\b/.test(value)) return 3; return null; } function getStoredPackageOption(index) { const numericIndex = Number(index); if (!Number.isFinite(numericIndex) || numericIndex < 1) return null; return lastPackageOptions.find(function (option, arrayIndex) { return Number(option && option.index || arrayIndex + 1) === numericIndex; }) || null; } function openPackageOption(index) { const option = getStoredPackageOption(index); if (!option) return false; const url = buildPackageCheckoutUrl(option); if (!url) { appendAssistantMessage(tx('openFailed'), 'Asistente de Viaje BNO con IA'); setStatus(tx('openFailed'), 'error'); return false; } setStatus(tx('opening', { count: index }), 'success'); window.location.assign(url); return true; } function packageCardDetails(option) { const room = getPackageRoom(option); const facts = []; const capacity = firstFiniteNumber(room.Capacity, room.capacity, room.TotalOccupancy, room.totalOccupancy); const bedrooms = firstFiniteNumber(room.Bedrooms, room.bedrooms, room.BedroomCount, room.bedroomCount, room.NumberOfBedrooms, room.numberOfBedrooms); const bathrooms = firstFiniteNumber(room.Bathrooms, room.bathrooms, room.BathroomCount, room.bathroomCount, room.NumberOfBathrooms, room.numberOfBathrooms); if (capacity !== null && capacity > 0) facts.push(tx('capacityFact', { count: capacity })); if (bedrooms !== null && bedrooms > 0) facts.push(tx('bedroomFact', { count: bedrooms })); if (bathrooms !== null && bathrooms > 0) facts.push(tx('bathroomFact', { count: bathrooms })); return { facts: facts.join(' · '), description: shortenText(firstText(room.Description, room.description), 210) }; } function packageOptionCardHtml(option, fallbackIndex, currencySnapshot) { const summary = option && option.packageSummary || {}; const accommodationAction = option && option.accommodationAction || {}; const checkoutAccommodation = option && option.checkoutPayload && option.checkoutPayload.accommodation || {}; const room = accommodationAction.room || checkoutAccommodation.room || {}; const optionIndex = Number(option && option.index || fallbackIndex || 1); const label = tx('proposal', { count: optionIndex }); const accom = firstText(summary.accommodationName, room.Name, room.name); const destination = firstText(summary.accommodationArea); const image = firstImageUrl(summary.accommodationImage, summary.accommodationImages, accommodationAction.image, accommodationAction.images, checkoutAccommodation.image, checkoutAccommodation.images, room.Image, room.image, room.Images, room.images); const details = packageCardDetails(option); const priceView = packagePriceView(option, currencySnapshot); const checkoutUrl = buildPackageCheckoutUrl(option); const accommodationPriceText = priceView.accommodationValid && priceView.accommodationDisplay ? priceView.accommodationDisplay : tx('priceCheck'); const flightPriceText = priceView.flightValid && priceView.flightDisplay ? priceView.flightDisplay : tx('priceCheck'); const totalText = priceView.complete && priceView.totalDisplay ? tx('approxTotal') + ' ' + priceView.totalDisplay : tx('totalCheck'); return '
' + escapeHtml(label) + '
' + richImageHtml(image, accom || destination || label) + (accom ? '
' + escapeHtml(accom) + '
' : (destination ? '
' + escapeHtml(destination) + '
' : '')) + (destination && accom ? '
' + escapeHtml(destination) + '
' : '') + (details.facts ? '
' + escapeHtml(details.facts) + '
' : '') + (details.description ? '
' + escapeHtml(details.description) + '
' : '') + (accom ? '
' + escapeHtml(tx('accommodation')) + ': ' + escapeHtml(accommodationPriceText) + '
' : '') + (priceView.flightValid ? '
' + escapeHtml(tx('flight')) + ': ' + escapeHtml(flightPriceText) + '
' : '') + '
' + escapeHtml(totalText) + '
' + (checkoutUrl ? '
' : '') + '
'; } function packageMorePromptHtml() { if (lastPackageVisibleCount >= lastPackageOptions.length) return ''; return '
' + escapeHtml(moreTx('prompt')) + '' + '
'; } function isMorePackageRequest(message) { if (!lastPackageOptions.length) return false; const value = normalizeIntentText(message).trim().replace(/[.!?]+$/g, ''); return /^(neste|vis flere|flere|mer|next|show more|more|nasta|nästa|visa fler|naeste|næste|vis flere|weiter|mehr|nachste|nächste|suivant|plus|siguiente|mas|más|avanti|altro|altri|volgende|meer|dalej|wiecej|więcej|seguinte|mais|seuraava|lisaa|lisää|naesta|næsta|fleiri|التالي|المزيد|अगला|और|berikutnya|lainnya|次|次へ|다음|더보기|ถัดไป|เพิ่มเติม|sonraki|daha fazla|下一个|更多)$/.test(value); } function removePackageMorePrompts() { chatEl.querySelectorAll('[data-bno-show-more-package]').forEach(function (button) { const box = button.closest('.bno-helper-more-box'); if (box) box.remove(); }); } async function showNextPackageOptions() { removePackageMorePrompts(); if (!lastPackageOptions.length || lastPackageVisibleCount >= lastPackageOptions.length) { appendAssistantMessage(moreTx('done'), 'Asistente de Viaje BNO con IA'); return false; } const currencySnapshot = await loadCurrencyRates(); const start = lastPackageVisibleCount; const nextOptions = lastPackageOptions.slice(start, start + 3); let nextHtml = ''; nextOptions.forEach(function (option, index) { nextHtml += packageOptionCardHtml(option, start + index + 1, currencySnapshot); }); lastPackageVisibleCount += nextOptions.length; if (lastPackageVisibleCount < lastPackageOptions.length) { nextHtml += packageMorePromptHtml(); } else { nextHtml += '
' + escapeHtml(moreTx('done')) + '
'; } appendRichHtml(nextHtml); return true; } async function renderTravelResponseExtras(data) { if (!data || typeof data !== 'object') return; let html = ''; const proposal = data.tripProposal && typeof data.tripProposal === 'object' ? data.tripProposal : null; const packageOptions = data.packageAction && Array.isArray(data.packageAction.options) ? data.packageAction.options : []; if (packageOptions.length) { lastPackageOptions = packageOptions.slice(); const currencySnapshot = await loadCurrencyRates(); lastPackageVisibleCount = Math.min(3, packageOptions.length); packageOptions.slice(0, lastPackageVisibleCount).forEach(function (option, index) { html += packageOptionCardHtml(option, index + 1, currencySnapshot); }); html += packageMorePromptHtml(); appendRichHtml(html); return; } if (proposal) { const destination = proposal.destination || {}; const accommodation = proposal.accommodation || null; const flight = proposal.flight || null; const destinationLabel = firstText(destination.label, destination.destinationSlug, destination.area); if (destinationLabel) { html += '
Destino
' + escapeHtml(destinationLabel) + '
'; } if (accommodation) { const name = firstText(accommodation.Name, accommodation.name, accommodation.title, accommodation.room && accommodation.room.Name); const image = firstImageUrl( accommodation.Image, accommodation.image, accommodation.Images, accommodation.images, accommodation.room && accommodation.room.Image, accommodation.room && accommodation.room.image, accommodation.room && accommodation.room.Images, accommodation.room && accommodation.room.images ); const price = formatMoney( accommodation.PriceTotal !== undefined ? accommodation.PriceTotal : (accommodation.priceTotal !== undefined ? accommodation.priceTotal : accommodation.totalPrice), firstText(accommodation.PriceCurrency, accommodation.priceCurrency, accommodation.currency) ); const url = cleanHttpUrl(firstText(accommodation.BookingUrl, accommodation.bookingUrl, accommodation.external_url)); html += '
Alojamiento
' + richImageHtml(image, name || 'Propuesta de alojamiento') + '
' + escapeHtml(name || 'Propuesta de alojamiento') + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Ver alojamiento' : '') + '
'; } if (flight) { const info = flightSummary(flight); if (info) { html += '
Vuelo
' + '
' + escapeHtml(info.airline || 'Sugerencia de vuelo') + '
' + (info.route ? '
' + escapeHtml(info.route) + '
' : '') + (info.price ? '
' + escapeHtml(info.price) + '
' : '') + '
'; } } const activities = Array.isArray(proposal.activities) ? proposal.activities : []; const restaurants = Array.isArray(proposal.restaurants) ? proposal.restaurants : []; if (activities.length || restaurants.length) { const labels = []; activities.slice(0, 3).forEach(function (item) { labels.push(firstText(item && item.name, item && item.title, item)); }); restaurants.slice(0, 3).forEach(function (item) { labels.push(firstText(item && item.name, item && item.title, item)); }); html += '
En el destino
' + labels.filter(Boolean).map(function (label) { return '' + escapeHtml(label) + ''; }).join('') + '
'; } } if (!proposal) { const rows = data.searchContext && Array.isArray(data.searchContext.rows) ? data.searchContext.rows : []; rows.slice(0, 3).forEach(function (row) { const name = firstText(row && row.Name, row && row.name, row && row.title); if (!name) return; const image = firstImageUrl(row && row.Image, row && row.image, row && row.Images, row && row.images); const price = formatMoney(row.PriceTotal !== undefined ? row.PriceTotal : row.priceTotal, firstText(row.PriceCurrency, row.priceCurrency, row.currency)); const url = cleanHttpUrl(firstText(row.BookingUrl, row.bookingUrl, row.external_url)); html += '
Alojamiento
' + richImageHtml(image, name) + '
' + escapeHtml(name) + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Ver opción' : '') + '
'; }); } const flightActions = []; if (Array.isArray(data.flightOfferActions)) flightActions.push.apply(flightActions, data.flightOfferActions); if (data.flightAction) flightActions.push(data.flightAction); if (!proposal || !proposal.flight) { flightActions.slice(0, 3).forEach(function (action) { const info = flightSummary(action && action.offer); if (!info) return; html += '
Vuelo
' + escapeHtml(info.airline || 'Sugerencia de vuelo') + '
' + (info.route ? '
' + escapeHtml(info.route) + '
' : '') + (info.price ? '
' + escapeHtml(info.price) + '
' : '') + '
'; }); } appendRichHtml(html); } function normalizeIntentText(value) { return String(value || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/å/g, 'a').replace(/æ/g, 'ae').replace(/ø/g, 'o'); } function classifyWebTravelIntent(message) { const value = normalizeIntentText(message); const categories = [ /overnatting|hytte|leilighet|hotell|hotel|accommodation|lodging/, /fly|flight|flybillett|avgang/, /aktivitet|restaurant|reiseforslag|reiseplan|skiferie|ferie/ ].filter(function (pattern) { return pattern.test(value); }).length; if (/reiseforslag|reiseplan|skiferie|familieferie|sett opp|lag et forslag/.test(value) || categories >= 2) return 'trip_planning'; if (/fly|flight|flybillett|avgang/.test(value)) return 'flights'; if (/overnatting|hytte|leilighet|hotell|hotel|accommodation|lodging/.test(value)) return 'accommodation'; if (/restaurant|middag|lunsj|mat/.test(value)) return 'restaurants'; if (/aktivitet|opplevelse|ski|museum/.test(value)) return 'activities'; return 'general'; } function extractWebDestinationHint(message) { const value = normalizeIntentText(message); const matches = [ ['portes-du-soleil', ['portes du soleil','morzine','les gets','avoriaz','chatel']], ['tignes-val-disere', ['tignes','val d isere','val disere']], ['chamonix', ['chamonix']], ['verbier', ['verbier']], ['trysil', ['trysil']], ['salen', ['salen','saelen']], ['stranda', ['stranda','strandafjellet']], ['grand-massif', ['grand massif','les carroz']], ['pays-du-mont-blanc', ['pays du mont blanc']], ['3-vallees', ['3 vallees','trois vallees','three valleys']], ['mallorca', ['mallorca','palma']], ['costa-brava', ['costa brava']], ['kaprun', ['kaprun']], ['zell-am-see', ['zell am see']], ['vemdalen', ['vemdalen']] ]; for (let i = 0; i < matches.length; i += 1) { if (matches[i][1].some(function (term) { return value.indexOf(term) >= 0; })) return matches[i][0]; } return null; } function showInitialHint() { if (chatEl.children.length) return; widget.classList.add('is-start'); chatEl.innerHTML = '
' + 'Prueba el Asistente de Viaje BNO con IA
' + 'Elige una sugerencia de abajo o escribe tu pregunta en el campo de la parte inferior.' + '
'; quickPromptsEl.style.display = ''; } function clearInitialHint() { widget.classList.remove('is-start'); const hint = chatEl.querySelector('.bno-helper-empty-state'); if (hint) hint.remove(); } function rememberDestinationFromResponse(data) { const candidates = [ data && data.tripProposal && data.tripProposal.destination && data.tripProposal.destination.destinationSlug, data && data.tripProposal && data.tripProposal.destination && data.tripProposal.destination.area, data && data.searchContext && data.searchContext.params && data.searchContext.params.area, data && data.detectedDestination ]; for (const candidate of candidates) { const value = String(candidate || '').trim(); if (value) { lastDestinationSlug = value.slice(0, 120); return; } } } function removeContactForm() { const card = chatEl.querySelector('[data-bno-contact-card]'); if (card) card.remove(); contactFormOpen = false; } function setSupportUiActive() { humanRowEl.classList.add('is-active'); humanTitleEl.textContent = 'Estás chateando con un miembro de nuestro equipo'; humanHintEl.textContent = 'Chat directo con el equipo de atención de BNO Travel'; humanButtonEl.classList.add('is-active'); humanButtonEl.innerHTML = 'Estás chateando con BNO Travel'; humanButtonEl.disabled = true; quickPromptsEl.style.display = 'none'; queryEl.placeholder = 'Escribe un mensaje a BNO Travel …'; endSupportBtn.hidden = false; } function appendSupportIntro() { clearInitialHint(); chatEl.insertAdjacentHTML('beforeend', '
BNO Travel
Ahora estás chateando con BNO Travel

Escribe en qué necesitas ayuda. Cuando responda un miembro del equipo de atención, la respuesta aparecerá aquí.

Si sales de la página antes de que podamos responder, podremos ponernos en contacto contigo mediante el correo electrónico que indicaste.

' ); scrollBottom(); } function showSupportContactForm() { if (supportToken) { enterSupportMode(); return; } if (contactFormOpen) { const existingName = chatEl.querySelector('[data-bno-contact-name]'); if (existingName) existingName.focus(); return; } clearInitialHint(); contactFormOpen = true; quickPromptsEl.style.display = 'none'; setMainInputEnabled(false); setStatus('Introduce tu nombre y correo electrónico para iniciar un chat con BNO Travel.'); chatEl.insertAdjacentHTML('beforeend', '
' + '
' + 'Chatea con un miembro de nuestro equipo' + '

Indica tu nombre y correo electrónico. Así podremos ponernos en contacto contigo si sales del sitio antes de que podamos responder.

' + '
' + '
' + '
' + '

Los datos de contacto se utilizan para responder a esta consulta.

' + '

' + '
' + '
' + '
' + '
' ); scrollBottom(); setTimeout(function () { const nameInput = chatEl.querySelector('[data-bno-contact-name]'); const emailInput = chatEl.querySelector('[data-bno-contact-email]'); if (nameInput && !nameInput.value) nameInput.focus(); else if (emailInput) emailInput.focus(); }, 50); } function cancelSupportContactForm() { removeContactForm(); if (!supportMode) quickPromptsEl.style.display = ''; setMainInputEnabled(true); setStatus(''); if (!chatEl.children.length) showInitialHint(); queryEl.focus(); } function enterSupportMode() { removeContactForm(); if (!supportMode) appendSupportIntro(); supportMode = true; setSupportUiActive(); setMainInputEnabled(true); setStatus('Chat directo con el equipo de atención de BNO Travel', 'success'); if (supportToken) startSupportPolling(); queryEl.focus(); } function submitSupportContactForm(form) { const nameInput = form.querySelector('[data-bno-contact-name]'); const emailInput = form.querySelector('[data-bno-contact-email]'); const errorEl = form.querySelector('[data-bno-contact-error]'); const name = String(nameInput && nameInput.value || '').trim(); const email = String(emailInput && emailInput.value || '').trim().toLowerCase(); if (!name) { errorEl.textContent = 'Introduce tu nombre.'; if (nameInput) nameInput.focus(); return; } if (!isValidEmail(email)) { errorEl.textContent = 'Introduce un correo electrónico válido.'; if (emailInput) emailInput.focus(); return; } errorEl.textContent = ''; saveSupportProfile({ name: name, email: email }); enterSupportMode(); } function resetSupportUi() { supportMode = false; contactFormOpen = false; supportToken = ''; supportProfile = { name: '', email: '' }; sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(PROFILE_KEY); seenSupportMessageIds.clear(); if (supportPollTimer) clearInterval(supportPollTimer); supportPollTimer = null; setSupportUiIdle(); setMainInputEnabled(true); setStatus(''); } function resetWholeConversation() { resetSupportUi(); travelHistory = []; lastDestinationSlug = ''; lastPackageOptions = []; lastPackageVisibleCount = 0; chatEl.innerHTML = ''; queryEl.value = ''; resizeInput(); showInitialHint(); scrollBottom(); } async function endSupportConversation() { const tokenToClose = supportToken; endSupportBtn.disabled = true; setStatus('Finalizando el chat …'); try { if (tokenToClose) { const response = await fetch(API_BASE + SUPPORT_ENDPOINT + '/conversations/' + encodeURIComponent(tokenToClose), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ status: 'closed' }) }); const data = await response.json().catch(function () { return null; }); if (!response.ok || !data || !data.ok) { throw new Error(data && data.error || 'support_close_failed'); } } resetWholeConversation(); setStatus('El chat ha finalizado. Puedes iniciar una nueva conversación.', 'success'); } catch (error) { console.error('[BNO SUPPORT CLOSE]', error); setStatus('No se ha podido finalizar el chat en este momento. Inténtalo de nuevo.', 'error'); } finally { endSupportBtn.disabled = false; } } async function sendTravelHelper(message) { clearInitialHint(); quickPromptsEl.style.display = 'none'; appendUserMessage(message); setStatus('El Asistente de Viaje BNO con IA está pensando …'); submitEl.disabled = true; const language = getLanguage(); const nextHistory = travelHistory.concat([{ role: 'user', text: message }]).slice(-16); try { const response = await fetch(API_BASE + TRAVEL_HELPER_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ message: message, lang: language, replyLanguage: language, detectedIntent: classifyWebTravelIntent(message), detectedDestination: extractWebDestinationHint(message), history: nextHistory, helperContext: { replyLanguage: language, detectedIntent: classifyWebTravelIntent(message), detectedDestination: extractWebDestinationHint(message), isTripPlanning: classifyWebTravelIntent(message) === 'trip_planning', source: 'bno-travel-web', pageUrl: window.location.href } }) }); const data = await response.json().catch(function () { return null; }); if (!response.ok || !data || !data.reply) { throw new Error(data && data.error || 'travel_helper_failed'); } rememberDestinationFromResponse(data); const reply = String(data.reply); const packageOptions = data.packageAction && Array.isArray(data.packageAction.options) ? data.packageAction.options : []; travelHistory = nextHistory.concat([{ role: 'assistant', text: reply }]).slice(-16); if (packageOptions.length) { lastPackageOptions = packageOptions.slice(); lastPackageVisibleCount = 0; const narrativeHtml = buildPackageNarrativeHtml(packageOptions); if (narrativeHtml) appendAssistantRichMessage(narrativeHtml, 'Asistente de Viaje BNO con IA'); else appendAssistantMessage(reply, 'Asistente de Viaje BNO con IA'); } else { appendAssistantMessage(reply, 'Asistente de Viaje BNO con IA'); } await renderTravelResponseExtras(data); setStatus(''); } catch (error) { console.error('[BNO WEB HELPER]', error); appendAssistantMessage('Lo sentimos, el Asistente de Viaje BNO con IA no está disponible en este momento. Aún puedes elegir «Chatea con un miembro de nuestro equipo» para contactar con BNO Travel.', 'Asistente de Viaje BNO con IA'); setStatus('No se ha podido obtener una respuesta del Asistente de Viaje BNO.', 'error'); } finally { submitEl.disabled = false; } } async function createSupportConversation(message) { if (!supportProfile.name || !isValidEmail(supportProfile.email)) { throw new Error('support_profile_required'); } const language = getLanguage(); const response = await fetch(API_BASE + SUPPORT_ENDPOINT + '/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ customerName: supportProfile.name, customerEmail: supportProfile.email, language: language, destinationSlug: lastDestinationSlug || null, pageUrl: window.location.href, initialMessage: message, travelHelperContext: { history: travelHistory.slice(-12), source: 'bno-travel-web', pageUrl: window.location.href } }) }); const data = await response.json().catch(function () { return null; }); if (!response.ok || !data || !data.ok || !data.data || !data.data.publicToken) { throw new Error(data && data.error || 'support_create_failed'); } supportToken = String(data.data.publicToken); sessionStorage.setItem(STORAGE_KEY, supportToken); startSupportPolling(); } async function sendSupportMessage(message) { clearInitialHint(); appendUserMessage(message); setStatus('Enviando a BNO Travel …'); submitEl.disabled = true; try { if (!supportToken) { await createSupportConversation(message); } else { const response = await fetch(API_BASE + SUPPORT_ENDPOINT + '/conversations/' + encodeURIComponent(supportToken) + '/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ body: message }) }); const data = await response.json().catch(function () { return null; }); if (!response.ok || !data || !data.ok) throw new Error(data && data.error || 'support_message_failed'); } setStatus('El mensaje se ha enviado a BNO Travel.', 'success'); await pollSupportMessages(); } catch (error) { console.error('[BNO SUPPORT CHAT]', error); if (String(error && error.message || '') === 'support_profile_required') { supportMode = false; setSupportUiIdle(); showSupportContactForm(); setStatus('Debes indicar el nombre y el correo electrónico antes de enviar el mensaje.', 'error'); } else { setStatus('No se ha podido enviar el mensaje. Inténtalo de nuevo.', 'error'); } } finally { if (!contactFormOpen) submitEl.disabled = false; } } async function pollSupportMessages() { if (!supportToken) return; try { const response = await fetch(API_BASE + SUPPORT_ENDPOINT + '/conversations/' + encodeURIComponent(supportToken) + '/messages', { cache: 'no-store' }); const data = await response.json().catch(function () { return null; }); if (!response.ok || !data || !data.ok) return; if (data.data && data.data.conversation && data.data.conversation.status === 'closed') { resetWholeConversation(); setStatus('Este chat de atención ha finalizado. Puedes iniciar una nueva conversación.', 'success'); return; } const messages = Array.isArray(data.data && data.data.messages) ? data.data.messages : []; const staffMessages = messages.filter(function (message) { return message.sender_type === 'staff'; }); staffMessages.forEach(function (message) { const messageId = String(message.id || ''); if (!messageId || seenSupportMessageIds.has(messageId)) return; const staffName = firstText(message.sender_name, message.staff_name, message.display_name); appendAssistantMessage(String(message.body || ''), staffName ? staffName + ', BNO Travel' : 'BNO Travel'); seenSupportMessageIds.add(messageId); }); } catch (error) { console.warn('[BNO SUPPORT POLL]', error); } } function startSupportPolling() { if (supportPollTimer) clearInterval(supportPollTimer); pollSupportMessages(); supportPollTimer = window.setInterval(pollSupportMessages, 8000); } async function handleSend(messageOverride) { if (contactFormOpen) return; const message = String(messageOverride || queryEl.value || '').trim(); if (!message) { setStatus('Escribe primero un mensaje.'); return; } queryEl.value = ''; resizeInput(); if (!supportMode) { const selectedPackageIndex = extractPackageChoiceIndex(message); if (selectedPackageIndex) { appendUserMessage(message); openPackageOption(selectedPackageIndex); return; } if (isMorePackageRequest(message)) { appendUserMessage(message); await showNextPackageOptions(); return; } } if (supportMode) await sendSupportMessage(message); else await sendTravelHelper(message); } launcher.addEventListener('click', openHelper); closeBtn.addEventListener('click', closeHelper); overlay.addEventListener('click', closeHelper); humanButtonEl.addEventListener('click', function () { showSupportContactForm(); }); endSupportBtn.addEventListener('click', function () { endSupportConversation(); }); submitEl.addEventListener('click', function () { handleSend(); }); queryEl.addEventListener('input', resizeInput); queryEl.addEventListener('keydown', function (event) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); handleSend(); } }); document.addEventListener('keydown', function (event) { if (event.key === 'Escape' && widget.classList.contains('is-open')) closeHelper(); }); quickPromptsEl.addEventListener('click', function (event) { const button = event.target.closest('button[data-prompt]'); if (!button || supportMode) return; handleSend(button.getAttribute('data-prompt') || ''); }); chatEl.addEventListener('click', function (event) { const packageButton = event.target.closest('[data-bno-package-option-index]'); if (packageButton && !supportMode) { event.preventDefault(); const index = Number(packageButton.getAttribute('data-bno-package-option-index')); openPackageOption(index); return; } const moreButton = event.target.closest('[data-bno-show-more-package]'); if (moreButton && !supportMode) { event.preventDefault(); moreButton.disabled = true; showNextPackageOptions(); return; } const cancel = event.target.closest('[data-bno-contact-cancel]'); if (cancel) { event.preventDefault(); cancelSupportContactForm(); } }); chatEl.addEventListener('submit', function (event) { const form = event.target.closest('[data-bno-contact-form]'); if (!form) return; event.preventDefault(); submitSupportContactForm(form); }); showInitialHint(); // Give the closed BNO Reisehjelper launcher one subtle attention cue on page load. window.setTimeout(startLauncherAttention, 900); if (supportToken) { supportMode = true; clearInitialHint(); appendSupportIntro(); setSupportUiActive(); setStatus('Chat directo con el equipo de atención de BNO Travel', 'success'); startSupportPolling(); } })();
`; chatEl.insertAdjacentHTML('beforeend', html); chatEl.scrollTop = chatEl.scrollHeight; } function getActionLink(item) { if (item.app_route) { return { href: item.app_route, label: 'Más información', className: 'bno-helper-link bno-helper-link-primary' }; } if (item.external_url) { return { href: item.external_url, label: 'Continuar', className: 'bno-helper-link bno-helper-link-primary' }; } return null; } function renderRelatedItems(items) { if (!items.length) return ''; return ` `; } function appendAssistantResult(items) { if (!items || !items.length) { const fallbackHtml = `
No he encontrado una respuesta suficientemente clara.

Prueba otra búsqueda, elige un destino o utiliza uno de los temas siguientes.

`; chatEl.insertAdjacentHTML('beforeend', fallbackHtml); chatEl.scrollTop = chatEl.scrollHeight; return; } const mainItem = items[0]; const relatedItems = items.slice(1, 4); const action = getActionLink(mainItem); const preview = mainItem.body ? truncate(mainItem.body, 240) : ''; const html = `
${escapeHtml(mainItem.title || 'Respuesta')}
${mainItem.destination_slug ? `${escapeHtml(mainItem.destination_slug)}` : ''} ${mainItem.category ? `${escapeHtml(mainItem.category)}` : ''}
${mainItem.summary ? `

${escapeHtml(mainItem.summary)}

` : ''} ${preview ? `

${escapeHtml(preview)}

` : ''} ${action ? ` ` : ''} ${relatedItems.length ? `
Respuestas relacionadas
${renderRelatedItems(relatedItems)} ` : ''}
`; chatEl.insertAdjacentHTML('beforeend', html); chatEl.scrollTop = chatEl.scrollHeight; } function normalizeItems(rawItems) { return (rawItems || []) .filter(item => item && item.is_active !== false) .sort((a, b) => { const featuredDiff = Number(Boolean(b.is_featured)) - Number(Boolean(a.is_featured)); if (featuredDiff !== 0) return featuredDiff; const sortA = Number.isFinite(Number(a.sort_order)) ? Number(a.sort_order) : 9999; const sortB = Number.isFinite(Number(b.sort_order)) ? Number(b.sort_order) : 9999; return sortA - sortB; }); } async function searchHelper() { const query = queryEl.value.trim(); const destination_slug = destinationEl.value; const category = activeCategory; if (!query && !category) { setStatus('Escribe una pregunta o elige un tema.'); return; } appendUserMessage(query || (category ? `Ver tema: ${category}` : 'Mostrar ayuda')); setStatus('Buscando en el Asistente de Viaje BNO …'); submitEl.disabled = true; submitEl.textContent = 'Buscando…'; try { const response = await fetch(`${API_BASE}${API_ENDPOINT}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ language: DEFAULT_LANGUAGE, destination_slug, category, query }) }); const result = await response.json(); if (!response.ok || !result?.ok) { throw new Error('No se puede obtener una respuesta del asistente en este momento.'); } const items = normalizeItems(result?.data?.items || []); appendAssistantResult(items); setStatus(items.length ? `${items.length} resultados encontrados` : 'Sin resultados'); } catch (error) { setStatus(error.message || 'Se ha producido un error.', 'error'); appendAssistantResult([]); } finally { submitEl.disabled = false; submitEl.textContent = 'Buscar'; queryEl.value = ''; } } function setActiveChip(button) { const buttons = chipsEl.querySelectorAll('button[data-category]'); buttons.forEach(btn => btn.classList.remove('is-active')); button.classList.add('is-active'); activeCategory = button.dataset.category || ''; } launcher.addEventListener('click', openHelper); closeBtn.addEventListener('click', closeHelper); overlay.addEventListener('click', closeHelper); document.addEventListener('keydown', function (event) { if (event.key === 'Escape' && widget.classList.contains('is-open')) { closeHelper(); } }); submitEl.addEventListener('click', searchHelper); queryEl.addEventListener('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); searchHelper(); } }); chipsEl.addEventListener('click', function (event) { const button = event.target.closest('button[data-category]'); if (!button) return; setActiveChip(button); searchHelper(); }); chatEl.addEventListener('click', function (event) { const quickLink = event.target.closest('[data-quick-query]'); if (!quickLink) return; event.preventDefault(); queryEl.value = quickLink.getAttribute('data-quick-query') || ''; searchHelper(); }); })();