BNO Travel
BNO Travel Plan uw reis eenvoudiger in de app
Openen

Plan uw reis eenvoudiger met BNO Travel

Vind accommodatie in geselecteerde bestemmingen, zoek vluchten voor reizen dichtbij en ver weg, en boek een huurauto die u verder vrijheid geeft. Alles verzameld in een overzichtelijke en gebruiksvriendelijke oplossing.

Eenvoudigere keuzes. Beter overzicht.

BNO Travel verzamelt belangrijke onderdelen van de reis in één oplossing – afgestemd op zowel de behoeften van vandaag als verdere groei.

Vind uw volgende bestemming

Van Noorse bergen en de Alpen tot zon, steden en verre reizen. Ontdek geselecteerde bestemmingen, vind accommodatie en plan uw reis verder met vluchten en een huurauto.

Uitgelichte bestemmingen

Bestemmingen waar BNO Travel een bijzonder sterk aanbod aan accommodaties heeft – van Scandinavië en de Alpen tot het Middellandse Zeegebied en Canada.

Bergen, skiën en natuur het hele jaar door

Van de Noorse en Zweedse bergen tot de Alpen. Bestemmingen voor skiën, fietsen, wandelen, gezinsvakanties en natuurbelevenissen in elk seizoen.

Trysil

Trysil

Skiën, fietsen, bergen en activiteiten voor het hele gezin, het hele jaar door.

Ontdek Trysil
Sälen

Sälen

Een Zweedse bergvakantie met skiën, activiteiten en gezinsbelevenissen.

Ontdek Sälen
Vemdalen

Vemdalen

Bergen, skiën en natuurbelevenissen in een van de populairste berggebieden van Zweden.

Ontdek Vemdalen
Nu beschikbaar Geilo

Geilo

Een klassieke Noorse bergbestemming met skiën, langlaufen, bergwandelingen en activiteiten het hele jaar door.

Ontdek Geilo
Nu beschikbaar Hafjell

Hafjell

Alpineskiën, fietsen en gezinsactiviteiten in een van de bekendste bergbestemmingen van Noorwegen.

Ontdek Hafjell

Zon & strand

Het Middellandse Zeegebied, eilandleven en warme bestemmingen voor strandvakanties, gezinsverblijven en ontspannen dagen.

Nu beschikbaar De Spaanse kust

De Spaanse kust

Stranden, eilanden, kustplaatsen en een klassieke mediterrane sfeer.

Ontdek de Spaanse kust
Nu beschikbaar Corfu

Corfu

Grieks eilandleven met helder water, stranden en kleine dorpen.

Ontdek Corfu
Nu beschikbaar Sarandë en Ksamil in Albanië

Sarandë & Ksamil

De zuidkust van Albanië met helder water, kleine baaien en een ontspannen vakantiesfeer.

Ontdek Sarandë & Ksamil
Nu beschikbaar Athene en de Atheense Rivièra

Athene & de Atheense Rivièra

Combineer stadsleven, Griekse eetcultuur en geschiedenis met stranden en kustleven langs de Atheense Rivièra.

Ontdek Athene & de Atheense Rivièra
Nu beschikbaar Dubai

Dubai

Zon, strand, een moderne wereldstad, restaurants, winkels en belevenissen het hele jaar door.

Ontdek Dubai
Binnenkort Mexico

Mexico

Stranden, cultuur, eten en belevenissen in een van de meest gevarieerde vakantielanden ter wereld.

Binnenkort

Steden

Cultuur, winkelen, restaurants en belevenissen in populaire steden in Europa en de rest van de wereld.

Nu beschikbaar Athene

Athene

Geschiedenis, Griekse eetcultuur, stadsleven en de kust en eilanden op korte afstand.

Ontdek Athene
Nu beschikbaar London

London

Winkelen, cultuur, voetbal, restaurants en klassieke stadsbelevenissen.

Ontdek London
Nu beschikbaar Paris

Paris

Gastronomie, architectuur, winkelen en wereldberoemde cultuur.

Ontdek Paris
Nu beschikbaar New York

New York

Winkelen, restaurants, cultuur en iconische stadsbelevenissen.

Ontdek New York
Nu beschikbaar Los Angeles

Los Angeles

Stadsleven, stranden, entertainment en iconische belevenissen in Californië.

Ontdek Los Angeles
Nu beschikbaar Miami

Miami

Strandleven, stedelijke energie, winkelen, restaurants en een tropische Florida-sfeer.

Ontdek Miami

Beleef Noorwegen

Van bergen en fjorden tot stadsleven, het noorderlicht en arctische belevenissen.

Nu beschikbaar Oslo

Oslo

Cultuur, restaurants, winkelen en fjordbelevenissen.

Ontdek Oslo
Nu beschikbaar Bergen

Bergen

Bryggen, bergen en een natuurlijk vertrekpunt voor fjordbelevenissen.

Ontdek Bergen
Nu beschikbaar Trondheim

Trondheim

Geschiedenis, culinaire belevenissen, de Nidaroskathedraal en een levendige stadssfeer het hele jaar door.

Ontdek Trondheim
Nu beschikbaar Tromsø

Tromsø

Noorderlicht, middernachtzon, bergen en arctisch stadsleven.

Ontdek Tromsø

Verre reizen & de wereld

Reis verder en beleef steden, tropische bestemmingen, Noord-Amerika en Azië.

Nu beschikbaar Ontario in Canada

Ontario, Canada

Meren, natuur en kleine plaatsen, met Toronto binnen handbereik.

Ontdek Ontario
Nu beschikbaar Dubai

Dubai

Strand, stadsleven, winkelen, restaurants en belevenissen in de zon.

Ontdek Dubai
Nu beschikbaar Bangkok

Bangkok

Eten, cultuur, winkelen en stadsleven – en een natuurlijk vertrekpunt voor verdere reizen door Azië.

Ontdek Bangkok
Nu beschikbaar New York

New York

Een van de bekendste steden ter wereld voor cultuur, eten, winkelen en belevenissen.

Ontdek New York
Nu beschikbaar Los Angeles

Los Angeles

Californië, stranden, stadsleven en iconische belevenissen.

Ontdek Los Angeles
Binnenkort Mexico

Mexico

Stranden, geschiedenis, eetcultuur en kleurrijke bestemmingen.

Binnenkort

Vind meer bestemmingen

Ons aanbod groeit snel. Bekijk alle beschikbare accommodaties en vind de bestemming die bij uw reis past.

Bekijk alle bestemmingen
🏡

Accommodatie in geselecteerde bestemmingen

Ontdek zorgvuldig geselecteerde verblijven in populaire gebieden en reisbestemmingen, met oplossingen die passen bij zowel korte reizen als langere verblijven.

Bekijk accommodatie
✈️

Vluchten afgestemd op uw reis

Zoek vluchten naar bestemmingen in binnen- en buitenland, en vind opties die bij uw plannen passen.

Zoek vluchten
🚗

Huurauto voor flexibelere reizen

Vind een huurauto die het eenvoudig maakt om verder te reizen, of u nu naar stad, bergen, kust of andere reisbestemmingen gaat.

Vind een huurauto

Een overzichtelijkere en flexibelere manier om de reis te plannen

BNO Travel is gebouwd om reisplanning eenvoudiger te maken. Met duidelijke ingangen naar accommodatie, vluchten en huurauto krijgt u een overzichtelijkere ervaring – zowel wanneer u vandaag iets concreets wilt boeken als wanneer u mogelijkheden voor de volgende reis wilt verkennen.

Lees meer over BNO Travel
Duidelijke keuzes en eenvoudige navigatie
Accommodatie in geselecteerde bestemmingen
Vluchten en huurauto voor een bredere reismarkt
Een oplossing die is gebouwd voor verdere groei

BNO Travel-app – Houd beter overzicht over uw reis, ook onderweg

BNO Travel is ontwikkeld om goed te werken op zowel de website als in de app. Start de planning waar het u het beste uitkomt, en gebruik dezelfde oplossing om overzicht te houden over de verdere reis.

  • Plan eenvoudig op mobiel en desktop
  • Krijg een meer samenhangende reiservaring
  • Volg uw reis in een oplossing die is gemaakt om mee te groeien met uw behoeften
BNO Travel app

Verhuren met BNO Travel

Heeft u een hut, appartement of andere accommodatie in een aantrekkelijk gebied? BNO Travel geeft eigenaren en verhuurders de mogelijkheid om deel te worden van een professioneel reisplatform met focus op presentatie, zichtbaarheid en een overzichtelijke ervaring voor gasten.

Wij maken het mogelijk dat accommodaties in geselecteerde bestemmingen op een betrouwbare en commerciële manier worden gepresenteerd, als onderdeel van een complete reiservaring. Het doel is om het eenvoudiger te maken relevante gasten te bereiken en uw aanbod in een sterkere digitale context te presenteren.

Verhuren met BNO Travel
' ); scrollBottom(); } function appendAssistantMessage(text, label) { const safeLabel = String(label || 'BNO AI-reisassistent').trim(); chatEl.insertAdjacentHTML('beforeend', '
' + (safeLabel ? '
' + escapeHtml(safeLabel) + '
' : '') + '

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

' ); scrollBottom(); } function appendAssistantRichMessage(html, label) { if (!html) return; const safeLabel = String(label || 'BNO AI-reisassistent').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'), 'BNO AI-reisassistent'); 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'), 'BNO AI-reisassistent'); 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 += '
Bestemming
' + 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 += '
Accommodatie
' + richImageHtml(image, name || 'Accommodatievoorstel') + '
' + escapeHtml(name || 'Accommodatievoorstel') + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Bekijk accommodatie' : '') + '
'; } if (flight) { const info = flightSummary(flight); if (info) { html += '
Vlucht
' + '
' + escapeHtml(info.airline || 'Vluchtvoorstel') + '
' + (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 += '
Op de bestemming
' + 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 += '
Accommodatie
' + richImageHtml(image, name) + '
' + escapeHtml(name) + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Bekijk optie' : '') + '
'; }); } 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 += '
Vlucht
' + escapeHtml(info.airline || 'Vluchtvoorstel') + '
' + (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 = '
' + 'Probeer de BNO AI-reisassistent
' + 'Kies hieronder een suggestie of typ uw vraag in het veld onderaan.' + '
'; 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 = 'U chat met een van onze medewerkers'; humanHintEl.textContent = 'Directe klantenchat met BNO Travel'; humanButtonEl.classList.add('is-active'); humanButtonEl.innerHTML = 'U chat met BNO Travel'; humanButtonEl.disabled = true; quickPromptsEl.style.display = 'none'; queryEl.placeholder = 'Schrijf een bericht aan BNO Travel …'; endSupportBtn.hidden = false; } function appendSupportIntro() { clearInitialHint(); chatEl.insertAdjacentHTML('beforeend', '
BNO Travel
U chat nu met BNO Travel

Schrijf waarmee u hulp nodig hebt. Zodra een servicemedewerker antwoordt, verschijnt het antwoord hier.

Als u de pagina verlaat voordat we kunnen antwoorden, kunnen we contact met u opnemen via het opgegeven e-mailadres.

' ); 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('Vul uw naam en e-mailadres in om een chat met BNO Travel te starten.'); chatEl.insertAdjacentHTML('beforeend', '
' + '
' + 'Chat met een van onze medewerkers' + '

Vul uw naam en e-mailadres in. Zo kunnen we contact met u opnemen als u de website verlaat voordat we kunnen antwoorden.

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

Uw contactgegevens worden gebruikt om deze vraag te beantwoorden.

' + '

' + '
' + '
' + '
' + '
' ); 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('Directe klantenchat met 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 = 'Vul uw naam in.'; if (nameInput) nameInput.focus(); return; } if (!isValidEmail(email)) { errorEl.textContent = 'Vul een geldig e-mailadres in.'; 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('Chat wordt beëindigd …'); 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('De chat is beëindigd. U kunt een nieuw gesprek starten.', 'success'); } catch (error) { console.error('[BNO SUPPORT CLOSE]', error); setStatus('De chat kan op dit moment niet worden beëindigd. Probeer het opnieuw.', 'error'); } finally { endSupportBtn.disabled = false; } } async function sendTravelHelper(message) { clearInitialHint(); quickPromptsEl.style.display = 'none'; appendUserMessage(message); setStatus('De BNO AI-reisassistent denkt na …'); 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, 'BNO AI-reisassistent'); else appendAssistantMessage(reply, 'BNO AI-reisassistent'); } else { appendAssistantMessage(reply, 'BNO AI-reisassistent'); } await renderTravelResponseExtras(data); setStatus(''); } catch (error) { console.error('[BNO WEB HELPER]', error); appendAssistantMessage('Sorry, de BNO AI-reisassistent is momenteel niet beschikbaar. U kunt nog steeds ‘Chat met een van onze medewerkers’ kiezen om contact op te nemen met BNO Travel.', 'BNO AI-reisassistent'); setStatus('Er kon geen antwoord van BNO Reisassistent worden opgehaald.', '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('Wordt verzonden naar 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('Uw bericht is verzonden naar 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('Naam en e-mailadres zijn vereist voordat het bericht kan worden verzonden.', 'error'); } else { setStatus('Het bericht kon niet worden verzonden. Probeer het opnieuw.', '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('Deze klantenchat is beëindigd. U kunt een nieuw gesprek starten.', '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('Schrijf eerst een bericht.'); 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('Directe klantenchat met 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: 'Meer lezen', className: 'bno-helper-link bno-helper-link-primary' }; } if (item.external_url) { return { href: item.external_url, label: 'Verder', 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 = `
Ik kon daar geen duidelijk antwoord op vinden.

Probeer een andere zoekopdracht, kies een bestemming of gebruik een van de onderwerpen hieronder.

`; 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 || 'Antwoord')}
${mainItem.destination_slug ? `${escapeHtml(mainItem.destination_slug)}` : ''} ${mainItem.category ? `${escapeHtml(mainItem.category)}` : ''}
${mainItem.summary ? `

${escapeHtml(mainItem.summary)}

` : ''} ${preview ? `

${escapeHtml(preview)}

` : ''} ${action ? ` ` : ''} ${relatedItems.length ? `
Gerelateerde antwoorden
${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('Stel een vraag of kies een onderwerp.'); return; } appendUserMessage(query || (category ? `Toon onderwerp: ${category}` : 'Toon hulp')); setStatus('Zoeken in BNO Reisassistent …'); submitEl.disabled = true; submitEl.textContent = 'Zoeken…'; 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('De hulpresultaten kunnen momenteel niet worden opgehaald.'); } const items = normalizeItems(result?.data?.items || []); appendAssistantResult(items); setStatus(items.length ? `${items.length} resultaten gevonden` : 'Geen resultaten'); } catch (error) { setStatus(error.message || 'Er is een fout opgetreden.', 'error'); appendAssistantResult([]); } finally { submitEl.disabled = false; submitEl.textContent = 'Zoeken'; 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(); }); })();