BNO Travel
BNO Travel Suunnittele matkasi helpommin sovelluksessa
Avaa

Suunnittele matkasi helpommin BNO Travelin avulla

Löydä majoitus valituista kohteista, etsi lentoja lähelle ja kauas sekä varaa vuokra-auto, joka antaa vapautta jatkaa matkaa. Kaikki selkeässä ja helppokäyttöisessä palvelussa.

Helpompia valintoja. Parempi kokonaiskuva.

BNO Travel kokoaa matkan tärkeät osat yhteen palveluun – tämän päivän tarpeisiin ja tulevaan kasvuun suunniteltuna.

Löydä seuraava matkakohteesi

Norjan vuorilta ja Alpeilta aurinkoon, kaupunkeihin ja kaukomatkoille. Tutustu valittuihin kohteisiin, löydä majoitus ja jatka matkan suunnittelua lentojen ja vuokra-auton avulla.

Valitut kohteet

Kohteita, joissa BNO Travelilla on erityisen vahva majoitustarjonta – Skandinaviasta ja Alpeilta Välimerelle ja Kanadaan.

Vuoret, hiihto ja luonto ympäri vuoden

Norjan ja Ruotsin vuorilta Alpeille. Kohteita hiihtoon, pyöräilyyn, patikointiin, perhelomiin ja luontoelämyksiin kaikkina vuodenaikoina.

Trysil

Trysil

Hiihtoa, pyöräilyä, vuoria ja aktiviteetteja koko perheelle ympäri vuoden.

Tutustu Trysiliin
Sälen

Sälen

Ruotsalainen vuoristoloma, jossa on hiihtoa, aktiviteetteja ja elämyksiä perheille.

Tutustu Säleniin
Vemdalen

Vemdalen

Vuoria, hiihtoa ja luontoelämyksiä yhdellä Ruotsin suosituimmista vuoristoalueista.

Tutustu Vemdaleniin
Saatavilla nyt Geilo

Geilo

Klassinen norjalainen vuoristokohde, jossa on laskettelua, maastohiihtoa, vuoristovaelluksia ja aktiviteetteja ympäri vuoden.

Tutustu kohteeseen Geilo
Saatavilla nyt Hafjell

Hafjell

Alppihiihtoa, pyöräilyä ja perheaktiviteetteja yhdessä Norjan tunnetuimmista vuoristokohteista.

Tutustu kohteeseen Hafjell

Aurinko & ranta

Välimeri, saarielämä ja lämpimät kohteet rantalomille, perhelomille ja rentouttaviin päiviin.

Saatavilla nyt Korfu

Korfu

Kreikkalaista saarielämää, kirkasta vettä, rantoja ja pieniä kyliä.

Tutustu kohteeseen Korfu
Saatavilla nyt Dubai

Dubai

Aurinkoa, rantaa, modernia suurkaupunkia, ravintoloita, ostoksia ja elämyksiä ympäri vuoden.

Tutustu kohteeseen Dubai
Tulossa pian Meksiko

Meksiko

Rantoja, kulttuuria, ruokaa ja elämyksiä yhdessä maailman monipuolisimmista lomamaista.

Tulossa pian

Kaupungit

Kulttuuria, ostoksia, ravintoloita ja elämyksiä suosituissa kaupungeissa Euroopassa ja muualla maailmassa.

Saatavilla nyt Ateena

Ateena

Historiaa, kreikkalaista ruokakulttuuria, kaupunkielämää sekä lyhyt matka rannikolle ja saarille.

Tutustu kohteeseen Ateena
Saatavilla nyt London

London

Ostoksia, kulttuuria, jalkapalloa, ravintoloita ja klassisia kaupunkielämyksiä.

Tutustu kohteeseen London
Saatavilla nyt Miami

Miami

Rantaelämää, suurkaupungin sykettä, ostoksia, ravintoloita ja trooppista Floridan tunnelmaa.

Tutustu kohteeseen Miami

Koe Norja

Vuorista ja vuonoista suurkaupunkeihin, revontuliin ja arktisiin elämyksiin.

Saatavilla nyt Trondheim

Trondheim

Historiaa, ruokaelämyksiä, Nidarosin tuomiokirkko ja elävä kaupunkitunnelma ympäri vuoden.

Tutustu kohteeseen Trondheim

Kaukomatkat & maailma

Matkusta kauemmas ja koe suurkaupunkeja, trooppisia kohteita, Pohjois-Amerikkaa ja Aasiaa.

Saatavilla nyt Ontario Kanadassa

Ontario, Kanada

Järviä, luontoa ja pikkukaupunkeja, Toronto helposti saavutettavissa.

Tutustu Ontarioon
Saatavilla nyt Dubai

Dubai

Rantaa, suurkaupunkia, ostoksia, ravintoloita ja elämyksiä auringossa.

Tutustu kohteeseen Dubai
Saatavilla nyt Bangkok

Bangkok

Ruokaa, kulttuuria, ostoksia ja suurkaupunkielämää – sekä luonteva lähtöpiste jatkomatkoille Aasiassa.

Tutustu kohteeseen Bangkok
Saatavilla nyt New York

New York

Yksi maailman tunnetuimmista suurkaupungeista kulttuurin, ruoan, ostosten ja elämysten ystäville.

Tutustu kohteeseen New York
Tulossa pian Meksiko

Meksiko

Rantoja, historiaa, ruokakulttuuria ja värikkäitä matkakohteita.

Tulossa pian

Löydä lisää matkakohteita

Valikoimamme kasvaa nopeasti. Katso kaikki saatavilla olevat majoitusvaihtoehdot ja löydä matkallesi sopiva kohde.

Katso kaikki kohteet
🏡

Majoitus valituissa kohteissa

Tutustu huolella valittuihin majoitusvaihtoehtoihin suosituilla alueilla ja matkakohteissa – ratkaisuja sekä lyhyille matkoille että pidempiin oleskeluihin.

Katso majoitusvaihtoehdot
✈️

Matkaasi sopivat lennot

Etsi lentoja kotimaan ja ulkomaan kohteisiin ja löydä suunnitelmiisi sopivat vaihtoehdot.

Etsi lentoja
🚗

Vuokra-auto joustavampaan matkantekoon

Löydä vuokra-auto, jolla matka jatkuu helposti kaupunkiin, vuorille, rannikolle tai muihin kohteisiin.

Löydä vuokra-auto

Selkeämpi ja joustavampi tapa suunnitella matkaa

BNO Travel on rakennettu helpottamaan matkan suunnittelua. Selkeät reitit majoitukseen, lentoihin ja vuokra-autoihin antavat paremman kokonaiskuvan sekä silloin, kun haluat varata jotain heti, että silloin, kun kartoitat vaihtoehtoja seuraavaa matkaa varten.

Lue lisää BNO Travelista
Selkeät valinnat ja helppo navigointi
Majoitus valituissa kohteissa
Lennot ja vuokra-autot laajemmille matkustusmarkkinoille
Ratkaisu, joka on rakennettu tulevaa kasvua varten

BNO Travel App – pidä matkasi paremmin hallinnassa myös liikkeellä

BNO Travel on suunniteltu toimimaan hyvin sekä verkkosivustolla että sovelluksessa. Aloita suunnittelu sinulle sopivalla tavalla ja käytä samaa palvelua matkan kokonaisuuden hallintaan.

  • Suunnittele helposti mobiililaitteella ja tietokoneella
  • Kokoa matkakokemus paremmin yhteen
  • Seuraa matkaasi palvelussa, joka kasvaa tarpeidesi mukana
BNO Travel -sovellus

Vuokraa BNO Travelin kautta

Onko sinulla mökki, huoneisto tai muu majoituskohde kiinnostavalla alueella? BNO Travel tarjoaa omistajille ja vuokranantajille mahdollisuuden liittyä ammattimaiseen matkailualustaan, jossa painotetaan laadukasta esittelyä, näkyvyyttä ja selkeää asiakaskokemusta.

Autamme esittelemään valituissa kohteissa sijaitsevia majoituspaikkoja luotettavasti ja kaupallisesti osana kokonaisvaltaista matkakokemusta. Tavoitteena on helpottaa sopivien vieraiden tavoittamista ja esitellä tarjontasi vahvemmassa digitaalisessa ympäristössä.

Vuokraa BNO Travelin kautta
' ); scrollBottom(); } function appendAssistantMessage(text, label) { const safeLabel = String(label || 'BNO AI-matka-apuri').trim(); chatEl.insertAdjacentHTML('beforeend', '
' + (safeLabel ? '
' + escapeHtml(safeLabel) + '
' : '') + '

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

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

' + paragraph + '

'; }).join('') + '
'; } function normalizeBookingAreaKey(value) { return String(value || '') .trim() .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/æ/g, 'ae') .replace(/ø/g, 'o') .replace(/å/g, 'a') .replace(/_/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-'); } function resolvePackageBookingTarget(option) { const accommodation = getPackageAccommodation(option); const room = getPackageRoom(option); const summary = option && option.packageSummary || {}; const serviceId = String(firstText(accommodation.serviceId, room.ServiceId, room.serviceId)).trim().toLowerCase(); const explicitPartner = String(firstText(accommodation.partnerKey, room.partnerKey, room.credsKey, room.CredsKey)).trim().toUpperCase(); const rawArea = firstText(accommodation.areaKey, accommodation.area, summary.accommodationArea, room.ServiceName, room.serviceName); const area = normalizeBookingAreaKey(rawArea); const lookup = normalizeIntentText([ rawArea, room.Name, room.name, room.ServiceName, room.serviceName, room.Description, room.description ].filter(Boolean).join(' ')); const directByService = { 'a3394d26-63df-4f01-987e-ad3400cb4c9e': ['EMERALD_3_VALLEES', '3-vallees'], 'c7c0ce0f-ea30-42ac-9386-abf4013be2c1': ['EMERALD_ALPE_D_HUEZ', 'alpe-d-huez'], '29b058bd-c9ec-4681-aec6-aab800c1ae36': ['EMERALD_CHAMONIX', 'chamonix'], 'f1850807-897b-44db-b7ca-b229010044f1': ['EMERALD_CHAMPAGNY_EN_VANOISE', 'champagny-en-vanoise'], 'f481f0c4-eca9-453a-a7ba-b21000e4acd0': ['EMERALD_CHATEL_PORTES_DU_SOLEIL', 'chatel-portes-du-soleil'], '640c91fc-8d00-429e-85eb-b0a5008b8b8e': ['EMERALD_GRAND_MASSIF', 'grand-massif'], 'd895c4c5-f9f7-40cd-8e36-ac8000f9dfb7': ['EMERALD_PAYS_DU_MONT_BLANC', 'pays-du-mont-blanc'], '02cb0384-861e-486f-8b52-b0a50089eefd': ['EMERALD_VERBIER', 'verbier'], '7cb1382d-ec46-4719-9919-ad3400c91cea': ['EMERALD_COSTA_BRAVA', 'costa-brava'], 'b40b3c57-a945-4998-8d9f-ae8500e281fd': ['EMERALD_MALLORCA', 'mallorca'], 'e7a52fec-74b4-4f75-af18-b36800c3d81c': ['EMERALD_TIGNES_VAL_D_ISERE', 'tignes-val-disere'], '8dfb9109-42ac-4413-b193-1a5664b3a215': ['EMERALD_PORTES_DU_SOLEIL', 'portes-du-soleil'], '24477039-0c00-42b1-80bc-afe700d74b58': ['ALPIN_FAMILY_AVENIDA_PANORAMA_SUITES', 'kaprun-avenida-panorama-suites'], 'e263f6f4-651a-4ed8-b3c2-b26700598970': ['ELMHIRST_RESORT', 'elmhirst-resort'], '11f4b043-fefc-496f-9680-b15a007eea68': ['STRANDA', 'stranda'], '76630f0a-4a54-412f-a693-b455005db08a': ['BNO_TRYSIL', 'vemdalen'], '2d94a493-81f2-4651-9faf-b3bb0097bdeb': ['BNO_TRYSIL', 'trysil-sentrum'], '9f600b99-057d-4a35-90a4-b30600bd0c64': ['BNO_TRYSIL', 'trysil-turistsenter'], 'ba0090d5-a22d-4836-a5de-b3140084990e': ['BNO_TRYSIL', 'trysilfjell-hytteomrade'], 'c4964b1d-2303-4ebd-9be8-b3140084d802': ['BNO_TRYSIL', 'trysil-hoyfjellssenter'] }; if (serviceId && directByService[serviceId]) { return { partnerKey: directByService[serviceId][0], areaKey: directByService[serviceId][1] }; } const areaMappings = [ ['mallorca', 'EMERALD_MALLORCA', 'mallorca'], ['costa-brava', 'EMERALD_COSTA_BRAVA', 'costa-brava'], ['3-vallees', 'EMERALD_3_VALLEES', '3-vallees'], ['alpe-d-huez', 'EMERALD_ALPE_D_HUEZ', 'alpe-d-huez'], ['chamonix', 'EMERALD_CHAMONIX', 'chamonix'], ['champagny-en-vanoise', 'EMERALD_CHAMPAGNY_EN_VANOISE', 'champagny-en-vanoise'], ['chatel-portes-du-soleil', 'EMERALD_CHATEL_PORTES_DU_SOLEIL', 'chatel-portes-du-soleil'], ['grand-massif', 'EMERALD_GRAND_MASSIF', 'grand-massif'], ['pays-du-mont-blanc', 'EMERALD_PAYS_DU_MONT_BLANC', 'pays-du-mont-blanc'], ['verbier', 'EMERALD_VERBIER', 'verbier'], ['tignes-val-disere', 'EMERALD_TIGNES_VAL_D_ISERE', 'tignes-val-disere'], ['emerald-tignes-val-d-isere', 'EMERALD_TIGNES_VAL_D_ISERE', 'tignes-val-disere'], ['portes-du-soleil', 'EMERALD_PORTES_DU_SOLEIL', 'portes-du-soleil'], ['kaprun-avenida-panorama-suites', 'ALPIN_FAMILY_AVENIDA_PANORAMA_SUITES', 'kaprun-avenida-panorama-suites'], ['kaprun', 'ALPIN_FAMILY_AVENIDA_PANORAMA_SUITES', 'kaprun-avenida-panorama-suites'], ['elmhirst-resort', 'ELMHIRST_RESORT', 'elmhirst-resort'], ['sunnmorsalpene', 'STRANDA', 'stranda'], ['strandafjellet', 'STRANDA', 'stranda'], ['fjellsaetra', 'STRANDA', 'stranda'], ['hornindal', 'STRANDA', 'stranda'], ['stranda', 'STRANDA', 'stranda'], ['vemdalen', 'BNO_TRYSIL', 'vemdalen'] ]; for (let i = 0; i < areaMappings.length; i += 1) { const mapping = areaMappings[i]; if (area === mapping[0] || area.includes(mapping[0]) || lookup.includes(mapping[0].replace(/-/g, ' '))) { return { partnerKey: mapping[1], areaKey: mapping[2] }; } } if (area.startsWith('trysil-')) return { partnerKey: 'BNO_TRYSIL', areaKey: area }; if (lookup.includes('vestsidevegen')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysil-sentrum' }; if (lookup.includes('litunet')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysil-turistsenter' }; if (lookup.includes('ugla')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysilfjell-hytteomrade' }; if (lookup.includes('trysilbua') || lookup.includes('fagerhoy') || lookup.includes('hoyfjell')) { return { partnerKey: 'BNO_TRYSIL', areaKey: 'trysil-hoyfjellssenter' }; } if (lookup.includes('lindvallen')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'lindvallen-salen' }; if (lookup.includes('tandadalen')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'tandadalen-salen' }; if (lookup.includes('hogfjallet')) return { partnerKey: 'BNO_TRYSIL', areaKey: 'hogfjallet-salen' }; if (explicitPartner && explicitPartner !== 'DEFAULT' && area) { return { partnerKey: explicitPartner, areaKey: area }; } return null; } function compactFlightPoint(point) { if (!point || typeof point !== 'object') return null; return { iata_code: firstText(point.iata_code, point.iataCode) || undefined, city_name: firstText(point.city_name, point.cityName) || undefined, name: firstText(point.name) || undefined }; } function compactFlightCarrier(carrier) { if (!carrier || typeof carrier !== 'object') return null; return { name: firstText(carrier.name) || undefined, iata_code: firstText(carrier.iata_code, carrier.iataCode) || undefined }; } function compactFlightOffer(offer) { if (!offer || typeof offer !== 'object' || !offer.id) return null; return { id: offer.id, total_amount: offer.total_amount, total_currency: offer.total_currency, total_with_fee: offer.total_with_fee, service_fee_amount: offer.service_fee_amount, fare_brand_name: offer.fare_brand_name, brand_name: offer.brand_name, fare_name: offer.fare_name, fare_brand: offer.fare_brand, fare_family: offer.fare_family, owner: compactFlightCarrier(offer.owner), passengers: Array.isArray(offer.passengers) ? offer.passengers.map(function (passenger) { return { id: passenger && passenger.id, passenger_id: passenger && passenger.passenger_id, type: passenger && passenger.type }; }).filter(function (passenger) { return passenger.id || passenger.passenger_id; }) : [], slices: Array.isArray(offer.slices) ? offer.slices.map(function (slice) { return { id: slice && slice.id, origin: compactFlightPoint(slice && slice.origin), destination: compactFlightPoint(slice && slice.destination), segments: slice && Array.isArray(slice.segments) ? slice.segments.map(function (segment) { return { id: segment && segment.id, departing_at: segment && segment.departing_at, arriving_at: segment && segment.arriving_at, duration: segment && segment.duration, origin: compactFlightPoint(segment && segment.origin), destination: compactFlightPoint(segment && segment.destination), marketing_carrier: compactFlightCarrier(segment && segment.marketing_carrier), operating_carrier: compactFlightCarrier(segment && segment.operating_carrier) }; }) : [] }; }) : [] }; } function buildTravelHelperCheckoutPayload(option, target) { const accommodation = getPackageAccommodation(option); const room = getPackageRoom(option); const checkoutFlight = option && option.checkoutPayload && option.checkoutPayload.flight || {}; const flight = getPackageFlight(option); const flightSearch = getPackageFlightSearch(option); return { accommodation: { partnerKey: target.partnerKey, areaKey: target.areaKey, roomId: firstText(accommodation.roomId, room.RoomCategoryId, room.ResourceCategoryId) || null, categoryId: firstText(accommodation.categoryId, room.ResourceCategoryId, room.RoomCategoryId) || null, serviceId: firstText(accommodation.serviceId, room.ServiceId, room.serviceId) || null, area: firstText(accommodation.area, target.areaKey) || null, from: firstText(accommodation.from) || null, to: firstText(accommodation.to) || null, adults: firstFiniteNumber(accommodation.adults), lang: firstText(accommodation.lang, getLanguage()) || null, room: { ResourceCategoryId: firstText(room.ResourceCategoryId, room.RoomCategoryId, accommodation.categoryId, accommodation.roomId) || null, RoomCategoryId: firstText(room.RoomCategoryId, room.ResourceCategoryId, accommodation.roomId, accommodation.categoryId) || null, Name: firstText(room.Name, room.name, option && option.packageSummary && option.packageSummary.accommodationName) || null, Description: firstText(room.Description, room.description) || null, Capacity: firstFiniteNumber(room.Capacity, room.capacity, room.TotalOccupancy, room.totalOccupancy), Image: firstImageUrl(room.Image, room.image, option && option.packageSummary && option.packageSummary.accommodationImage) || null, Images: Array.isArray(room.Images) ? room.Images : (Array.isArray(room.images) ? room.images : []), PriceTotal: room.PriceTotal !== undefined ? room.PriceTotal : room.priceTotal, PriceCurrency: firstText(room.PriceCurrency, room.priceCurrency) || null, ServiceId: firstText(room.ServiceId, room.serviceId, accommodation.serviceId) || null, ServiceName: firstText(room.ServiceName, room.serviceName, option && option.packageSummary && option.packageSummary.accommodationArea) || null, credsKey: firstText(room.credsKey, room.CredsKey, accommodation.partnerKey, target.partnerKey) || null } }, flight: flight && flight.id ? { offerId: firstText(checkoutFlight.offerId, flight.id) || null, offer: compactFlightOffer(flight), search: flightSearch } : null }; } function buildPackageCheckoutUrl(option) { if (!option) return ''; const target = resolvePackageBookingTarget(option); if (!target) return ''; const accommodation = getPackageAccommodation(option); const room = getPackageRoom(option); const resourceCategoryId = firstText( accommodation.categoryId, accommodation.roomId, room.ResourceCategoryId, room.RoomCategoryId ); const from = firstText(accommodation.from, option && option.accommodationAction && option.accommodationAction.search && option.accommodationAction.search.from); const to = firstText(accommodation.to, option && option.accommodationAction && option.accommodationAction.search && option.accommodationAction.search.to); const adults = firstFiniteNumber(accommodation.adults, option && option.accommodationAction && option.accommodationAction.search && option.accommodationAction.search.adults) || 2; const propertyName = firstText( option && option.packageSummary && option.packageSummary.accommodationName, room.Name, room.name ); const imageUrl = firstImageUrl( option && option.packageSummary && option.packageSummary.accommodationImage, option && option.packageSummary && option.packageSummary.accommodationImages, room.Image, room.image, room.Images, room.images ); if (!resourceCategoryId || !from || !to) return ''; const params = new URLSearchParams(); params.set('partnerKey', target.partnerKey); params.set('areaKey', target.areaKey); params.set('resourceCategoryId', resourceCategoryId); params.set('from', from); params.set('to', to); params.set('adults', String(Math.max(1, adults))); if (propertyName) params.set('propertyName', propertyName); if (imageUrl) params.set('imageUrl', imageUrl); params.set('lang', firstText(accommodation.lang, getLanguage()) || 'nb'); params.set('source', 'travel-helper-web'); const payload = buildTravelHelperCheckoutPayload(option, target); if (payload && payload.flight && payload.flight.offerId) { params.set('travelHelperPackagePayload', JSON.stringify(payload)); } return BOOKING_ENGINE_BASE + '/booking/complete?' + params.toString(); } function extractPackageChoiceIndex(message) { if (!lastPackageOptions.length) return null; const value = normalizeIntentText(message); const numeric = value.match(/(?:velg|velger|ta|tar|onsker|vil ha|alternativ|option|nummer|nr)\s*(?:alternativ\s*)?(\d+)/); if (numeric && numeric[1]) return Number(numeric[1]); if (/\b(forste|første)\b/.test(value) && /\b(velg|velger|ta|tar|alternativ|option|vil ha)\b/.test(value)) return 1; if (/\bandre\b/.test(value) && /\b(velg|velger|ta|tar|alternativ|option|vil ha)\b/.test(value)) return 2; if (/\btredje\b/.test(value) && /\b(velg|velger|ta|tar|alternativ|option|vil ha)\b/.test(value)) return 3; return null; } function getStoredPackageOption(index) { const numericIndex = Number(index); if (!Number.isFinite(numericIndex) || numericIndex < 1) return null; return lastPackageOptions.find(function (option, arrayIndex) { return Number(option && option.index || arrayIndex + 1) === numericIndex; }) || null; } function openPackageOption(index) { const option = getStoredPackageOption(index); if (!option) return false; const url = buildPackageCheckoutUrl(option); if (!url) { appendAssistantMessage(tx('openFailed'), 'BNO AI-matka-apuri'); setStatus(tx('openFailed'), 'error'); return false; } setStatus(tx('opening', { count: index }), 'success'); window.location.assign(url); return true; } function packageCardDetails(option) { const room = getPackageRoom(option); const facts = []; const capacity = firstFiniteNumber(room.Capacity, room.capacity, room.TotalOccupancy, room.totalOccupancy); const bedrooms = firstFiniteNumber(room.Bedrooms, room.bedrooms, room.BedroomCount, room.bedroomCount, room.NumberOfBedrooms, room.numberOfBedrooms); const bathrooms = firstFiniteNumber(room.Bathrooms, room.bathrooms, room.BathroomCount, room.bathroomCount, room.NumberOfBathrooms, room.numberOfBathrooms); if (capacity !== null && capacity > 0) facts.push(tx('capacityFact', { count: capacity })); if (bedrooms !== null && bedrooms > 0) facts.push(tx('bedroomFact', { count: bedrooms })); if (bathrooms !== null && bathrooms > 0) facts.push(tx('bathroomFact', { count: bathrooms })); return { facts: facts.join(' · '), description: shortenText(firstText(room.Description, room.description), 210) }; } function packageOptionCardHtml(option, fallbackIndex, currencySnapshot) { const summary = option && option.packageSummary || {}; const accommodationAction = option && option.accommodationAction || {}; const checkoutAccommodation = option && option.checkoutPayload && option.checkoutPayload.accommodation || {}; const room = accommodationAction.room || checkoutAccommodation.room || {}; const optionIndex = Number(option && option.index || fallbackIndex || 1); const label = tx('proposal', { count: optionIndex }); const accom = firstText(summary.accommodationName, room.Name, room.name); const destination = firstText(summary.accommodationArea); const image = firstImageUrl(summary.accommodationImage, summary.accommodationImages, accommodationAction.image, accommodationAction.images, checkoutAccommodation.image, checkoutAccommodation.images, room.Image, room.image, room.Images, room.images); const details = packageCardDetails(option); const priceView = packagePriceView(option, currencySnapshot); const checkoutUrl = buildPackageCheckoutUrl(option); const accommodationPriceText = priceView.accommodationValid && priceView.accommodationDisplay ? priceView.accommodationDisplay : tx('priceCheck'); const flightPriceText = priceView.flightValid && priceView.flightDisplay ? priceView.flightDisplay : tx('priceCheck'); const totalText = priceView.complete && priceView.totalDisplay ? tx('approxTotal') + ' ' + priceView.totalDisplay : tx('totalCheck'); return '
' + escapeHtml(label) + '
' + richImageHtml(image, accom || destination || label) + (accom ? '
' + escapeHtml(accom) + '
' : (destination ? '
' + escapeHtml(destination) + '
' : '')) + (destination && accom ? '
' + escapeHtml(destination) + '
' : '') + (details.facts ? '
' + escapeHtml(details.facts) + '
' : '') + (details.description ? '
' + escapeHtml(details.description) + '
' : '') + (accom ? '
' + escapeHtml(tx('accommodation')) + ': ' + escapeHtml(accommodationPriceText) + '
' : '') + (priceView.flightValid ? '
' + escapeHtml(tx('flight')) + ': ' + escapeHtml(flightPriceText) + '
' : '') + '
' + escapeHtml(totalText) + '
' + (checkoutUrl ? '
' : '') + '
'; } function packageMorePromptHtml() { if (lastPackageVisibleCount >= lastPackageOptions.length) return ''; return '
' + escapeHtml(moreTx('prompt')) + '' + '
'; } function isMorePackageRequest(message) { if (!lastPackageOptions.length) return false; const value = normalizeIntentText(message).trim().replace(/[.!?]+$/g, ''); return /^(neste|vis flere|flere|mer|next|show more|more|nasta|nästa|visa fler|naeste|næste|vis flere|weiter|mehr|nachste|nächste|suivant|plus|siguiente|mas|más|avanti|altro|altri|volgende|meer|dalej|wiecej|więcej|seguinte|mais|seuraava|lisaa|lisää|naesta|næsta|fleiri|التالي|المزيد|अगला|और|berikutnya|lainnya|次|次へ|다음|더보기|ถัดไป|เพิ่มเติม|sonraki|daha fazla|下一个|更多)$/.test(value); } function removePackageMorePrompts() { chatEl.querySelectorAll('[data-bno-show-more-package]').forEach(function (button) { const box = button.closest('.bno-helper-more-box'); if (box) box.remove(); }); } async function showNextPackageOptions() { removePackageMorePrompts(); if (!lastPackageOptions.length || lastPackageVisibleCount >= lastPackageOptions.length) { appendAssistantMessage(moreTx('done'), 'BNO AI-matka-apuri'); 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 += '
Kohde
' + 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 += '
Majoitus
' + richImageHtml(image, name || 'Majoitusehdotus') + '
' + escapeHtml(name || 'Majoitusehdotus') + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Katso majoitus' : '') + '
'; } if (flight) { const info = flightSummary(flight); if (info) { html += '
Lento
' + '
' + escapeHtml(info.airline || 'Lentoehdotus') + '
' + (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 += '
Kohteessa
' + 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 += '
Majoitus
' + richImageHtml(image, name) + '
' + escapeHtml(name) + '
' + (price ? '
' + escapeHtml(price) + '
' : '') + (url ? 'Katso vaihtoehto' : '') + '
'; }); } 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 += '
Lento
' + escapeHtml(info.airline || 'Lentoehdotus') + '
' + (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 = '
' + 'Kokeile BNO AI-matka-apuria
' + 'Valitse alta ehdotus tai kirjoita kysymyksesi alareunan kenttään.' + '
'; 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 = 'Keskustelet tiimimme jäsenen kanssa'; humanHintEl.textContent = 'Suora asiakaspalveluchat BNO Travelin kanssa'; humanButtonEl.classList.add('is-active'); humanButtonEl.innerHTML = 'Keskustelet BNO Travelin kanssa'; humanButtonEl.disabled = true; quickPromptsEl.style.display = 'none'; queryEl.placeholder = 'Kirjoita viesti BNO Travelille …'; endSupportBtn.hidden = false; } function appendSupportIntro() { clearInitialHint(); chatEl.insertAdjacentHTML('beforeend', '
BNO Travel
Keskustelet nyt BNO Travelin kanssa

Kirjoita, missä tarvitset apua. Kun asiakaspalvelija vastaa, vastaus näkyy tässä.

Jos poistut sivulta ennen kuin ehdimme vastata, voimme ottaa sinuun yhteyttä antamaasi sähköpostiosoitteeseen.

' ); 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('Täytä nimi ja sähköpostiosoite aloittaaksesi keskustelun BNO Travelin kanssa.'); chatEl.insertAdjacentHTML('beforeend', '
' + '
' + 'Keskustele tiimimme jäsenen kanssa' + '

Anna nimi ja sähköpostiosoite. Näin voimme ottaa sinuun yhteyttä, jos poistut sivustolta ennen kuin ehdimme vastata.

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

Yhteystietojasi käytetään tämän yhteydenoton käsittelyyn.

' + '

' + '
' + '
' + '
' + '
' ); 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('Suora asiakaspalveluchat BNO Travelin kanssa', '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 = 'Kirjoita nimesi.'; if (nameInput) nameInput.focus(); return; } if (!isValidEmail(email)) { errorEl.textContent = 'Anna voimassa oleva sähköpostiosoite.'; 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('Keskustelua lopetetaan …'); 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('Keskustelu on päättynyt. Voit aloittaa uuden keskustelun.', 'success'); } catch (error) { console.error('[BNO SUPPORT CLOSE]', error); setStatus('Keskustelua ei voitu lopettaa juuri nyt. Yritä uudelleen.', 'error'); } finally { endSupportBtn.disabled = false; } } async function sendTravelHelper(message) { clearInitialHint(); quickPromptsEl.style.display = 'none'; appendUserMessage(message); setStatus('BNO AI-matka-apuri miettii …'); submitEl.disabled = true; const language = getLanguage(); const nextHistory = travelHistory.concat([{ role: 'user', text: message }]).slice(-16); try { const response = await fetch(API_BASE + TRAVEL_HELPER_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ message: message, lang: language, replyLanguage: language, detectedIntent: classifyWebTravelIntent(message), detectedDestination: extractWebDestinationHint(message), history: nextHistory, helperContext: { replyLanguage: language, detectedIntent: classifyWebTravelIntent(message), detectedDestination: extractWebDestinationHint(message), isTripPlanning: classifyWebTravelIntent(message) === 'trip_planning', source: 'bno-travel-web', pageUrl: window.location.href } }) }); const data = await response.json().catch(function () { return null; }); if (!response.ok || !data || !data.reply) { throw new Error(data && data.error || 'travel_helper_failed'); } rememberDestinationFromResponse(data); const reply = String(data.reply); const packageOptions = data.packageAction && Array.isArray(data.packageAction.options) ? data.packageAction.options : []; travelHistory = nextHistory.concat([{ role: 'assistant', text: reply }]).slice(-16); if (packageOptions.length) { lastPackageOptions = packageOptions.slice(); lastPackageVisibleCount = 0; const narrativeHtml = buildPackageNarrativeHtml(packageOptions); if (narrativeHtml) appendAssistantRichMessage(narrativeHtml, 'BNO AI-matka-apuri'); else appendAssistantMessage(reply, 'BNO AI-matka-apuri'); } else { appendAssistantMessage(reply, 'BNO AI-matka-apuri'); } await renderTravelResponseExtras(data); setStatus(''); } catch (error) { console.error('[BNO WEB HELPER]', error); appendAssistantMessage('Valitettavasti BNO AI-matka-apuri ei ole juuri nyt käytettävissä. Voit silti valita ”Keskustele tiimimme jäsenen kanssa” ottaaksesi yhteyttä BNO Traveliin.', 'BNO AI-matka-apuri'); setStatus('BNO Matka-apurilta ei voitu hakea vastausta.', '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('Lähetetään BNO Travelille …'); 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('Viestisi on lähetetty BNO Travelille.', 'success'); await pollSupportMessages(); } catch (error) { console.error('[BNO SUPPORT CHAT]', error); if (String(error && error.message || '') === 'support_profile_required') { supportMode = false; setSupportUiIdle(); showSupportContactForm(); setStatus('Nimi ja sähköpostiosoite on täytettävä ennen viestin lähettämistä.', 'error'); } else { setStatus('Viestiä ei voitu lähettää. Yritä uudelleen.', '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('Tämä asiakaspalveluchat on päättynyt. Voit aloittaa uuden keskustelun.', '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('Kirjoita ensin viesti.'); 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('Suora asiakaspalveluchat BNO Travelin kanssa', 'success'); startSupportPolling(); } })();
`; chatEl.insertAdjacentHTML('beforeend', html); chatEl.scrollTop = chatEl.scrollHeight; } function getActionLink(item) { if (item.app_route) { return { href: item.app_route, label: 'Lue lisää', className: 'bno-helper-link bno-helper-link-primary' }; } if (item.external_url) { return { href: item.external_url, label: 'Jatka', 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 = `
En löytänyt tähän selkeää vastausta.

Kokeile toista hakua, valitse kohde tai käytä jotakin alla olevista aiheista.

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

${escapeHtml(mainItem.summary)}

` : ''} ${preview ? `

${escapeHtml(preview)}

` : ''} ${action ? ` ` : ''} ${relatedItems.length ? `
Aiheeseen liittyvät vastaukset
${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('Kirjoita kysymys tai valitse aihe.'); return; } appendUserMessage(query || (category ? `Näytä aihe: ${category}` : 'Näytä ohjeet')); setStatus('Haetaan BNO Matka-apurista …'); submitEl.disabled = true; submitEl.textContent = 'Haetaan…'; 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('Ohjevastauksia ei voitu hakea juuri nyt.'); } const items = normalizeItems(result?.data?.items || []); appendAssistantResult(items); setStatus(items.length ? `${items.length} osumaa löytyi` : 'Ei osumia'); } catch (error) { setStatus(error.message || 'Tapahtui virhe.', 'error'); appendAssistantResult([]); } finally { submitEl.disabled = false; submitEl.textContent = 'Hae'; 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(); }); })();