BNO Travel
BNO Travel Planlæg rejsen nemmere i appen
Åbn

Planlæg rejsen nemmere med BNO Travel

Find overnatning på udvalgte destinationer, søg efter fly til rejser nær og fjern, og book en lejebil, der giver dig frihed på rejsen. Alt samlet i en overskuelig og brugervenlig løsning.

Nemmere valg. Bedre overblik.

BNO Travel samler vigtige dele af rejsen i én løsning – tilpasset både dine behov i dag og fremtidig vækst.

Find dit næste rejsemål

Fra norske fjelde og Alperne til sol, storbyer og langrejser. Udforsk udvalgte rejsemål, find overnatning, og planlæg rejsen videre med fly og lejebil.

Udvalgte rejsemål

Rejsemål, hvor BNO Travel har et særligt stærkt udvalg af overnatningssteder – fra Skandinavien og Alperne til Middelhavet og Canada.

Bjerge, ski og natur hele året

Fra norske og svenske fjelde til Alperne. Rejsemål til ski, cykling, vandring, familieferier og naturoplevelser hele året.

Trysil

Trysil

Ski, cykling, fjelde og aktiviteter for hele familien året rundt.

Udforsk Trysil
Sälen

Sälen

Svensk fjeldferie med ski, aktiviteter og familieoplevelser.

Udforsk Sälen
Vemdalen

Vemdalen

Fjelde, ski og naturoplevelser i et af Sveriges mest populære fjeldområder.

Udforsk Vemdalen
Tilgængelig nu Geilo

Geilo

En klassisk norsk fjelddestination med ski, langrend, fjeldvandring og aktiviteter hele året.

Udforsk Geilo
Tilgængelig nu Hafjell

Hafjell

Alpint skiløb, cykling og familieaktiviteter i en af Norges mest kendte fjelddestinationer.

Udforsk Hafjell

Sol & strand

Middelhavet, øliv og varme rejsemål til strandferier, familieophold og afslappende dage.

Tilgængelig nu Korfu

Korfu

Græsk øliv med klart vand, strande og små landsbyer.

Udforsk Korfu
Tilgængelig nu Sarandë og Ksamil i Albanien

Sarandë & Ksamil

Albaniens sydkyst med klart vand, små bugter og en afslappet feriestemning.

Udforsk Sarandë & Ksamil
Tilgængelig nu Athen og den athenske riviera

Athen & den athenske riviera

Kombinér storbyliv, græsk madkultur og historie med strande og kystliv langs den athenske riviera.

Udforsk Athen & den athenske riviera
Tilgængelig nu Dubai

Dubai

Sol, strand, moderne storby, restauranter, shopping og oplevelser hele året.

Udforsk Dubai
Kommer snart Mexico

Mexico

Strande, kultur, mad og oplevelser i et af verdens mest varierede ferielande.

Kommer snart

Storbyer

Kultur, shopping, restauranter og oplevelser i populære storbyer i Europa og resten af verden.

Tilgængelig nu Athen

Athen

Historie, græsk madkultur, storbyliv og kort afstand til kyst og øer.

Udforsk Athen
Tilgængelig nu London

London

Shopping, kultur, fodbold, restauranter og klassiske storbyoplevelser.

Udforsk London
Tilgængelig nu Paris

Paris

Gastronomi, arkitektur, shopping og verdenskendt kultur.

Udforsk Paris
Tilgængelig nu New York

New York

Shopping, restauranter, kultur og ikoniske storbyoplevelser.

Udforsk New York
Tilgængelig nu Los Angeles

Los Angeles

Storby, strande, underholdning og ikoniske oplevelser i Californien.

Udforsk Los Angeles
Tilgængelig nu Miami

Miami

Strandliv, storbypuls, shopping, restauranter og tropisk Florida-stemning.

Udforsk Miami

Oplev Norge

Fra fjelde og fjorde til storby, nordlys og arktiske oplevelser.

Tilgængelig nu Oslo

Oslo

Kultur, restauranter, shopping og fjordoplevelser.

Udforsk Oslo
Tilgængelig nu Bergen

Bergen

Bryggen, fjelde og et naturligt udgangspunkt for fjordoplevelser.

Udforsk Bergen
Tilgængelig nu Trondheim

Trondheim

Historie, madoplevelser, Nidarosdomen og et levende bymiljø hele året.

Udforsk Trondheim
Tilgængelig nu Tromsø

Tromsø

Nordlys, midnatssol, fjelde og arktisk byliv.

Udforsk Tromsø

Langrejser & verden

Rejs længere, og oplev storbyer, tropiske rejsemål, Nordamerika og Asien.

Tilgængelig nu Ontario i Canada

Ontario, Canada

Søer, natur og småbyer med Toronto inden for rækkevidde.

Udforsk Ontario
Tilgængelig nu Dubai

Dubai

Strand, storby, shopping, restauranter og oplevelser i solen.

Udforsk Dubai
Tilgængelig nu Bangkok

Bangkok

Mad, kultur, shopping og storbyliv – og et naturligt udgangspunkt for videre rejser i Asien.

Udforsk Bangkok
Tilgængelig nu New York

New York

En af verdens mest kendte storbyer for kultur, mad, shopping og oplevelser.

Udforsk New York
Tilgængelig nu Los Angeles

Los Angeles

Californien, strande, storby og ikoniske oplevelser.

Udforsk Los Angeles
Kommer snart Mexico

Mexico

Strande, historie, madkultur og farverige rejsemål.

Kommer snart

Find flere rejsemål

Vores udvalg vokser hurtigt. Se alle tilgængelige overnatningsmuligheder, og find det rejsemål, der passer til din rejse.

Se alle destinationer
🏡

Overnatning på udvalgte destinationer

Udforsk nøje udvalgte ophold i populære områder og rejsemål med løsninger, der passer til både korte ture og længere ophold.

Se overnatning
✈️

Flyrejser tilpasset din rejse

Søg efter flyrejser til destinationer i ind- og udland, og find alternativer, der passer til dine planer.

Søg fly
🚗

Lejebil til mere fleksible rejser

Find en lejebil, der gør det nemt at rejse videre, uanset om du skal til en by, bjergene, kysten eller andre rejsemål.

Find lejebil

En mere overskuelig og fleksibel måde at planlægge rejsen på

BNO Travel er udviklet til at gøre rejseplanlægning enklere. Med tydelige indgange til overnatning, fly og lejebil får du en mere overskuelig oplevelse – både når du skal booke noget konkret i dag, og når du vil udforske mulighederne for din næste rejse.

Læs mere om BNO Travel
Tydelige valg og enkel navigation
Overnatning på udvalgte destinationer
Fly og lejebil til et bredere rejsemarked
En løsning, der er bygget til videre vækst

BNO Travel App – Få bedre overblik over rejsen, også når du er på farten

BNO Travel er udviklet til at fungere godt både på hjemmesiden og i appen. Start planlægningen dér, hvor det passer dig bedst, og brug den samme løsning til at holde overblik over resten af rejsen.

  • Planlæg nemt på mobil og computer
  • Få en mere sammenhængende rejseoplevelse
  • Følg din rejse i en løsning, der er skabt til at vokse med dine behov
BNO Travel app

Udlej med BNO Travel

Har du en hytte, lejlighed eller et andet overnatningssted i et attraktivt område? BNO Travel giver ejere og udlejere mulighed for at blive en del af en professionel rejseplatform med fokus på præsentation, synlighed og en overskuelig oplevelse for gæsterne.

Vi gør det muligt for overnatningssteder på udvalgte destinationer at blive præsenteret på en tillidsvækkende og kommerciel måde som en del af en samlet rejseoplevelse. Målet er at gøre det lettere at nå relevante gæster og præsentere dit tilbud i en stærkere digital sammenhæng.

Udlej med BNO Travel
' ); scrollBottom(); } function appendAssistantMessage(text, label) { const safeLabel = String(label || 'BNO Rejsehjælper AI').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 Rejsehjælper AI').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 Rejsehjælper AI'); 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 Rejsehjælper AI'); 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 += '
Rejsemål
' + 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 += '
Overnatning
' + richImageHtml(image, name || 'Forslag til overnatning') + '
' + escapeHtml(name || 'Forslag til overnatning') + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Se overnatning' : '') + '
'; } if (flight) { const info = flightSummary(flight); if (info) { html += '
Fly
' + '
' + escapeHtml(info.airline || 'Flyforslag') + '
' + (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 += '
På rejsemålet
' + 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 += '
Overnatning
' + richImageHtml(image, name) + '
' + escapeHtml(name) + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Se alternativ' : '') + '
'; }); } 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 += '
Fly
' + escapeHtml(info.airline || 'Flyforslag') + '
' + (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 = '
' + 'Prøv BNO Rejsehjælper AI
' + 'Vælg et forslag nedenfor, eller skriv dit spørgsmål i feltet nederst.' + '
'; 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 = 'Du chatter med en af vores medarbejdere'; humanHintEl.textContent = 'Direkte kundechat med BNO Travel'; humanButtonEl.classList.add('is-active'); humanButtonEl.innerHTML = 'Du chatter med BNO Travel'; humanButtonEl.disabled = true; quickPromptsEl.style.display = 'none'; queryEl.placeholder = 'Skriv en besked til BNO Travel …'; endSupportBtn.hidden = false; } function appendSupportIntro() { clearInitialHint(); chatEl.insertAdjacentHTML('beforeend', '
BNO Travel
Du chatter nu med BNO Travel

Skriv, hvad du har brug for hjælp til. Når en servicemedarbejder svarer, vises svaret her.

Hvis du forlader siden, før vi når at svare, kan vi følge op via den e-mailadresse, du har angivet.

' ); 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('Udfyld navn og e-mailadresse for at starte en chat med BNO Travel.'); chatEl.insertAdjacentHTML('beforeend', '
' + '
' + 'Chat med en af vores medarbejdere' + '

Angiv navn og e-mailadresse. Så kan vi følge op, hvis du forlader hjemmesiden, før vi når at svare.

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

Kontaktoplysningerne bruges til at besvare din henvendelse.

' + '

' + '
' + '
' + '
' + '
' ); 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('Direkte kundechat med 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 = 'Indtast dit navn.'; if (nameInput) nameInput.focus(); return; } if (!isValidEmail(email)) { errorEl.textContent = 'Indtast en gyldig e-mailadresse.'; 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('Afslutter chatten …'); 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('Chatten er afsluttet. Du kan starte en ny samtale.', 'success'); } catch (error) { console.error('[BNO SUPPORT CLOSE]', error); setStatus('Chatten kunne ikke afsluttes lige nu. Prøv igen.', 'error'); } finally { endSupportBtn.disabled = false; } } async function sendTravelHelper(message) { clearInitialHint(); quickPromptsEl.style.display = 'none'; appendUserMessage(message); setStatus('BNO Rejsehjælper AI tænker …'); 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 Rejsehjælper AI'); else appendAssistantMessage(reply, 'BNO Rejsehjælper AI'); } else { appendAssistantMessage(reply, 'BNO Rejsehjælper AI'); } await renderTravelResponseExtras(data); setStatus(''); } catch (error) { console.error('[BNO WEB HELPER]', error); appendAssistantMessage('Beklager, BNO Rejsehjælper AI er ikke tilgængelig lige nu. Du kan stadig vælge »Chat med en af vores medarbejdere« for at kontakte BNO Travel.', 'BNO Rejsehjælper AI'); setStatus('Kunne ikke hente et svar fra BNO Rejsehjælper.', '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('Sender til 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('Beskeden er sendt til 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('Navn og e-mailadresse skal udfyldes, før beskeden kan sendes.', 'error'); } else { setStatus('Beskeden kunne ikke sendes. Prøv igen.', '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('Denne kundechat er afsluttet. Du kan starte en ny samtale.', '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('Skriv en besked først.'); 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('Direkte kundechat med 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: 'Læs mere', className: 'bno-helper-link bno-helper-link-primary' }; } if (item.external_url) { return { href: item.external_url, label: 'Gå videre', 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 = `
Jeg fandt ikke et tydeligt svar på det.

Prøv en anden søgning, vælg en destination eller brug et af emnerne nedenfor.

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

${escapeHtml(mainItem.summary)}

` : ''} ${preview ? `

${escapeHtml(preview)}

` : ''} ${action ? ` ` : ''} ${relatedItems.length ? `
Relaterede svar
${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('Skriv et spørgsmål eller vælg et emne.'); return; } appendUserMessage(query || (category ? `Vis emne: ${category}` : 'Vis hjælp')); setStatus('Søger i BNO Rejsehjælper …'); submitEl.disabled = true; submitEl.textContent = 'Søger…'; 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('Hjælpesvaret kunne ikke hentes lige nu.'); } const items = normalizeItems(result?.data?.items || []); appendAssistantResult(items); setStatus(items.length ? `${items.length} resultater fundet` : 'Ingen resultater'); } catch (error) { setStatus(error.message || 'Der opstod en fejl.', 'error'); appendAssistantResult([]); } finally { submitEl.disabled = false; submitEl.textContent = 'Søg'; 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(); }); })();