BNO Travel
BNO Travel Planen Sie Ihre Reise einfacher in der App
Öffnen

Planen Sie Ihre Reise einfacher mit BNO Travel

Finden Sie Unterkünfte in ausgewählten Reisezielen, suchen Sie Flüge für Reisen in die Nähe und in die Ferne und buchen Sie einen Mietwagen, der Ihnen unterwegs Flexibilität bietet. Alles gebündelt in einer übersichtlichen und benutzerfreundlichen Lösung.

Einfachere Entscheidungen. Bessere Übersicht.

BNO Travel bündelt wichtige Bestandteile Ihrer Reise in einer Lösung – abgestimmt auf heutige Anforderungen und künftiges Wachstum.

Finden Sie Ihr nächstes Reiseziel

Von norwegischen Bergen und den Alpen bis zu Sonne, Metropolen und Fernreisen. Entdecken Sie ausgewählte Reiseziele, finden Sie Unterkünfte und planen Sie Ihre Reise mit Flügen und Mietwagen weiter.

Ausgewählte Reiseziele

Reiseziele, an denen BNO Travel ein besonders starkes Unterkunftsangebot bietet – von Skandinavien und den Alpen bis zum Mittelmeer und nach Kanada.

Berge, Skifahren und Natur das ganze Jahr über

Von norwegischen und schwedischen Bergen bis zu den Alpen. Reiseziele für Skifahren, Radfahren, Wandern, Familienurlaub und Naturerlebnisse zu jeder Jahreszeit.

Trysil

Trysil

Skifahren, Radfahren, Berge und Aktivitäten für die ganze Familie das ganze Jahr über.

Trysil entdecken
Sälen

Sälen

Schwedischer Bergurlaub mit Skifahren, Aktivitäten und Familienerlebnissen.

Sälen entdecken
Vemdalen

Vemdalen

Berge, Skifahren und Naturerlebnisse in einer der beliebtesten Bergregionen Schwedens.

Vemdalen entdecken
Jetzt verfügbar Geilo

Geilo

Ein klassisches norwegisches Bergreiseziel mit Skifahren, Langlauf, Bergwanderungen und Aktivitäten das ganze Jahr über.

Geilo entdecken
Jetzt verfügbar Hafjell

Hafjell

Alpiner Skisport, Radfahren und Familienaktivitäten in einem der bekanntesten Bergreiseziele Norwegens.

Hafjell entdecken

Sonne & Strand

Mittelmeer, Inselleben und warme Reiseziele für Strandurlaub, Familienaufenthalte und entspannte Tage.

Jetzt verfügbar Korfu

Korfu

Griechisches Inselleben mit klarem Wasser, Stränden und kleinen Dörfern.

Korfu entdecken
Jetzt verfügbar Sarandë und Ksamil in Albanien

Sarandë & Ksamil

Albaniens Südküste mit klarem Wasser, kleinen Buchten und entspannter Urlaubsatmosphäre.

Sarandë & Ksamil entdecken
Jetzt verfügbar Athen und die Athener Riviera

Athen & die Athener Riviera

Verbinden Sie Großstadtleben, griechische Esskultur und Geschichte mit Stränden und Küstenleben entlang der Athener Riviera.

Athen & die Athener Riviera entdecken
Jetzt verfügbar Dubai

Dubai

Sonne, Strand, moderne Metropole, Restaurants, Shopping und Erlebnisse das ganze Jahr über.

Dubai entdecken
Demnächst Mexiko

Mexiko

Strände, Kultur, Essen und Erlebnisse in einem der abwechslungsreichsten Urlaubsländer der Welt.

Demnächst

Metropolen

Kultur, Shopping, Restaurants und Erlebnisse in beliebten Städten Europas und der übrigen Welt.

Jetzt verfügbar Athen

Athen

Geschichte, griechische Esskultur, Großstadtleben und kurze Wege zu Küste und Inseln.

Athen entdecken
Jetzt verfügbar London

London

Shopping, Kultur, Fußball, Restaurants und klassische Großstadterlebnisse.

London entdecken
Jetzt verfügbar Paris

Paris

Gastronomie, Architektur, Shopping und weltberühmte Kultur.

Paris entdecken
Jetzt verfügbar New York

New York

Shopping, Restaurants, Kultur und ikonische Großstadterlebnisse.

New York entdecken
Jetzt verfügbar Los Angeles

Los Angeles

Großstadt, Strände, Unterhaltung und ikonische Erlebnisse in Kalifornien.

Los Angeles entdecken
Jetzt verfügbar Miami

Miami

Strandleben, Großstadtenergie, Shopping, Restaurants und tropisches Florida-Flair.

Miami entdecken

Norwegen erleben

Von Bergen und Fjorden bis zu Großstadt, Nordlichtern und arktischen Erlebnissen.

Jetzt verfügbar Oslo

Oslo

Kultur, Restaurants, Shopping und Fjorderlebnisse.

Oslo entdecken
Jetzt verfügbar Bergen

Bergen

Bryggen, Berge und ein idealer Ausgangspunkt für Fjorderlebnisse.

Bergen entdecken
Jetzt verfügbar Trondheim

Trondheim

Geschichte, kulinarische Erlebnisse, der Nidarosdom und ein lebendiges Stadtleben das ganze Jahr über.

Trondheim entdecken
Jetzt verfügbar Tromsø

Tromsø

Nordlichter, Mitternachtssonne, Berge und arktisches Stadtleben.

Tromsø entdecken

Fernreisen & die Welt

Reisen Sie weiter und erleben Sie Metropolen, tropische Reiseziele, Nordamerika und Asien.

Jetzt verfügbar Ontario in Kanada

Ontario, Kanada

Seen, Natur und Kleinstädte, mit Toronto in gut erreichbarer Nähe.

Ontario entdecken
Jetzt verfügbar Dubai

Dubai

Strand, Großstadt, Shopping, Restaurants und Erlebnisse in der Sonne.

Dubai entdecken
Jetzt verfügbar Bangkok

Bangkok

Essen, Kultur, Shopping und Großstadtleben – und ein idealer Ausgangspunkt für weitere Reisen in Asien.

Bangkok entdecken
Jetzt verfügbar New York

New York

Eine der bekanntesten Metropolen der Welt für Kultur, Essen, Shopping und Erlebnisse.

New York entdecken
Jetzt verfügbar Los Angeles

Los Angeles

Kalifornien, Strände, Großstadt und ikonische Erlebnisse.

Los Angeles entdecken
Demnächst Mexiko

Mexiko

Strände, Geschichte, Esskultur und farbenfrohe Reiseziele.

Demnächst

Weitere Reiseziele finden

Unser Angebot wächst schnell. Sehen Sie alle verfügbaren Unterkünfte und finden Sie das Reiseziel, das zu Ihrer Reise passt.

Alle Reiseziele ansehen
🏡

Unterkünfte in ausgewählten Reisezielen

Entdecken Sie sorgfältig ausgewählte Unterkünfte in beliebten Regionen und Reisezielen – mit Optionen für kurze Reisen ebenso wie für längere Aufenthalte.

Unterkünfte ansehen
✈️

Flüge passend zu Ihrer Reise

Suchen Sie nach Flügen zu Reisezielen im In- und Ausland und finden Sie Optionen, die zu Ihren Plänen passen.

Flüge suchen
🚗

Mietwagen für flexiblere Reisen

Finden Sie einen Mietwagen, mit dem Sie bequem weiterreisen können – ganz gleich, ob Ihr Ziel eine Stadt, die Berge, die Küste oder eine andere Region ist.

Mietwagen finden

Eine übersichtlichere und flexiblere Art der Reiseplanung

BNO Travel wurde entwickelt, um die Reiseplanung zu vereinfachen. Mit klaren Zugängen zu Unterkünften, Flügen und Mietwagen erhalten Sie eine übersichtlichere Nutzererfahrung – sowohl wenn Sie heute konkret buchen möchten als auch wenn Sie Möglichkeiten für Ihre nächste Reise entdecken wollen.

Mehr über BNO Travel erfahren
Klare Entscheidungen und einfache Navigation
Unterkünfte in ausgewählten Reisezielen
Flüge und Mietwagen für ein breiteres Reiseangebot
Eine Lösung, die auf weiteres Wachstum ausgelegt ist

BNO Travel App – Behalten Sie Ihre Reise auch unterwegs besser im Blick

BNO Travel wurde für eine optimale Nutzung sowohl auf der Website als auch in der App entwickelt. Beginnen Sie die Planung dort, wo es für Sie am besten passt, und nutzen Sie dieselbe Lösung, um Ihre Reise auch im weiteren Verlauf im Blick zu behalten.

  • Planen Sie bequem auf Mobilgeräten und am Desktop
  • Profitieren Sie von einem ganzheitlicheren Reiseerlebnis
  • Verfolgen Sie Ihre Reise in einer Lösung, die mit Ihren Bedürfnissen wächst
BNO Travel app

Mit BNO Travel vermieten

Besitzen Sie eine Hütte, eine Ferienwohnung oder eine andere Unterkunft in einer attraktiven Region? BNO Travel bietet Eigentümern und Vermietern die Möglichkeit, Teil einer professionellen Reiseplattform zu werden, die Wert auf Präsentation, Sichtbarkeit und ein übersichtliches Gästeerlebnis legt.

Wir schaffen die Voraussetzungen dafür, dass Unterkünfte in ausgewählten Reisezielen vertrauenswürdig und professionell präsentiert werden können – als Teil eines ganzheitlichen Reiseerlebnisses. Ziel ist es, relevante Gäste leichter zu erreichen und Ihr Angebot in einem stärkeren digitalen Umfeld zu präsentieren.

Mit BNO Travel vermieten
' ); scrollBottom(); } function appendAssistantMessage(text, label) { const safeLabel = String(label || 'BNO KI-Reiseassistent').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 KI-Reiseassistent').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 KI-Reiseassistent'); 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 KI-Reiseassistent'); 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 += '
Reiseziel
' + 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 += '
Unterkunft
' + richImageHtml(image, name || 'Unterkunftsvorschlag') + '
' + escapeHtml(name || 'Unterkunftsvorschlag') + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Unterkunft ansehen' : '') + '
'; } if (flight) { const info = flightSummary(flight); if (info) { html += '
Flug
' + '
' + escapeHtml(info.airline || 'Flugvorschlag') + '
' + (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 += '
Am Reiseziel
' + 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 += '
Unterkunft
' + richImageHtml(image, name) + '
' + escapeHtml(name) + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Option ansehen' : '') + '
'; }); } 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 += '
Flug
' + escapeHtml(info.airline || 'Flugvorschlag') + '
' + (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 = '
' + 'Testen Sie den BNO KI-Reiseassistenten
' + 'Wählen Sie unten einen Vorschlag aus oder schreiben Sie Ihre Frage in das Feld am unteren Rand.' + '
'; 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 = 'Sie chatten mit einem Mitglied unseres Teams'; humanHintEl.textContent = 'Direkter Kundenchat mit BNO Travel'; humanButtonEl.classList.add('is-active'); humanButtonEl.innerHTML = 'Sie chatten mit BNO Travel'; humanButtonEl.disabled = true; quickPromptsEl.style.display = 'none'; queryEl.placeholder = 'Schreiben Sie eine Nachricht an BNO Travel …'; endSupportBtn.hidden = false; } function appendSupportIntro() { clearInitialHint(); chatEl.insertAdjacentHTML('beforeend', '
BNO Travel
Sie chatten jetzt mit BNO Travel

Schreiben Sie, wobei Sie Hilfe benötigen. Sobald ein Servicemitarbeiter antwortet, erscheint die Antwort hier.

Wenn Sie die Seite verlassen, bevor wir antworten können, können wir Sie über die angegebene E-Mail-Adresse kontaktieren.

' ); 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('Geben Sie Ihren Namen und Ihre E-Mail-Adresse ein, um einen Chat mit BNO Travel zu starten.'); chatEl.insertAdjacentHTML('beforeend', '
' + '
' + 'Mit einem Mitglied unseres Teams chatten' + '

Geben Sie Ihren Namen und Ihre E-Mail-Adresse an. So können wir Sie kontaktieren, falls Sie die Website verlassen, bevor wir antworten können.

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

Ihre Kontaktdaten werden zur Bearbeitung dieser Anfrage verwendet.

' + '

' + '
' + '
' + '
' + '
' ); 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('Direkter Kundenchat mit 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 = 'Geben Sie Ihren Namen ein.'; if (nameInput) nameInput.focus(); return; } if (!isValidEmail(email)) { errorEl.textContent = 'Geben Sie eine gültige E-Mail-Adresse ein.'; 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 wird beendet …'); 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('Der Chat wurde beendet. Sie können eine neue Unterhaltung starten.', 'success'); } catch (error) { console.error('[BNO SUPPORT CLOSE]', error); setStatus('Der Chat konnte gerade nicht beendet werden. Bitte versuchen Sie es erneut.', 'error'); } finally { endSupportBtn.disabled = false; } } async function sendTravelHelper(message) { clearInitialHint(); quickPromptsEl.style.display = 'none'; appendUserMessage(message); setStatus('Der BNO KI-Reiseassistent denkt nach …'); 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 KI-Reiseassistent'); else appendAssistantMessage(reply, 'BNO KI-Reiseassistent'); } else { appendAssistantMessage(reply, 'BNO KI-Reiseassistent'); } await renderTravelResponseExtras(data); setStatus(''); } catch (error) { console.error('[BNO WEB HELPER]', error); appendAssistantMessage('Entschuldigung, der BNO KI-Reiseassistent ist derzeit nicht verfügbar. Sie können weiterhin „Mit einem Mitglied unseres Teams chatten“ auswählen, um BNO Travel zu kontaktieren.', 'BNO KI-Reiseassistent'); setStatus('Es konnte keine Antwort vom BNO Reiseassistenten abgerufen werden.', '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('Wird an BNO Travel gesendet …'); 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('Ihre Nachricht wurde an BNO Travel gesendet.', 'success'); await pollSupportMessages(); } catch (error) { console.error('[BNO SUPPORT CHAT]', error); if (String(error && error.message || '') === 'support_profile_required') { supportMode = false; setSupportUiIdle(); showSupportContactForm(); setStatus('Name und E-Mail-Adresse müssen ausgefüllt werden, bevor die Nachricht gesendet werden kann.', 'error'); } else { setStatus('Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut.', '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('Dieser Kundenchat wurde beendet. Sie können eine neue Unterhaltung 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('Schreiben Sie zuerst eine Nachricht.'); 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('Direkter Kundenchat mit 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: 'Mehr erfahren', className: 'bno-helper-link bno-helper-link-primary' }; } if (item.external_url) { return { href: item.external_url, label: 'Weiter', 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 = `
Ich konnte darauf keine eindeutige Antwort finden.

Versuchen Sie eine andere Suche, wählen Sie ein Reiseziel oder nutzen Sie eines der Themen unten.

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

${escapeHtml(mainItem.summary)}

` : ''} ${preview ? `

${escapeHtml(preview)}

` : ''} ${action ? ` ` : ''} ${relatedItems.length ? `
Verwandte Antworten
${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('Geben Sie eine Frage ein oder wählen Sie ein Thema.'); return; } appendUserMessage(query || (category ? `Thema anzeigen: ${category}` : 'Hilfe anzeigen')); setStatus('BNO Reiseassistent wird durchsucht …'); submitEl.disabled = true; submitEl.textContent = 'Suche läuft…'; 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('Die Hilfeantworten konnten derzeit nicht geladen werden.'); } const items = normalizeItems(result?.data?.items || []); appendAssistantResult(items); setStatus(items.length ? `${items.length} Treffer gefunden` : 'Keine Treffer'); } catch (error) { setStatus(error.message || 'Ein Fehler ist aufgetreten.', 'error'); appendAssistantResult([]); } finally { submitEl.disabled = false; submitEl.textContent = 'Suchen'; 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(); }); })();