'
);
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 '
' +
'
' +
'
';
}
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 ? '
' + escapeHtml(tx('chooseOption', { count: optionIndex })) + '
' : '') +
'
';
}
function packageMorePromptHtml() {
if (lastPackageVisibleCount >= lastPackageOptions.length) return '';
return '
' +
escapeHtml(moreTx('prompt')) +
'' + escapeHtml(moreTx('button')) + ' ' +
'
';
}
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',
'
'
);
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();
}
})();