(async function () { var DEFAULT_API_BASE = "https://samo.evroport.ru/vendor/evroport/api/index.php"; var mountNode = window.__ETOURS_TARGET || document.getElementById("etours"); if (!mountNode) { mountNode = document.querySelector('[data-widget-name="etours-extsearch"]'); } if (!mountNode) { return; } window.__ETOURS_TARGET = null; function resolveAssetBase() { var current = document.currentScript; if (current && current.src) { return current.src.replace(/[^/]*$/, ""); } var scripts = document.getElementsByTagName("script"); for (var i = scripts.length - 1; i >= 0; i -= 1) { var src = scripts[i].src || ""; if (/extsearch\.js(\?|$)/.test(src)) { return src.replace(/[^/]*$/, ""); } } return "./"; } function ensureStyle(href) { var existing = document.querySelector('link[data-etours-style="' + href + '"]'); if (existing) { return; } var link = document.createElement("link"); link.rel = "stylesheet"; link.href = href; link.setAttribute("data-etours-style", href); document.head.appendChild(link); } function ensureExtsearchCss(assetBase, version) { var baseHref = assetBase + "extsearch.css"; var href = baseHref + (version ? ("?v=" + encodeURIComponent(version)) : ("?v=" + Date.now())); var existing = document.querySelector('link[data-etours-style="' + baseHref + '"]'); if (existing) { if (existing.getAttribute("href") !== href) { existing.href = href; } return; } var link = document.createElement("link"); link.rel = "stylesheet"; link.href = href; link.setAttribute("data-etours-style", baseHref); document.head.appendChild(link); } function darkenHexColor(hex, factor) { var value = String(hex || "").trim(); var match = value.match(/^#([0-9a-f]{6})$/i); if (!match) { return ""; } var base = match[1]; var ratio = typeof factor === "number" ? factor : 0.18; var r = Math.max(0, Math.min(255, Math.round(parseInt(base.slice(0, 2), 16) * (1 - ratio)))); var g = Math.max(0, Math.min(255, Math.round(parseInt(base.slice(2, 4), 16) * (1 - ratio)))); var b = Math.max(0, Math.min(255, Math.round(parseInt(base.slice(4, 6), 16) * (1 - ratio)))); return "#" + [r, g, b].map(function (n) { var h = n.toString(16); return h.length === 1 ? "0" + h : h; }).join(""); } function loadScript(src) { return new Promise(function (resolve, reject) { if (document.querySelector('script[data-etours-script="' + src + '"]')) { resolve(); return; } var script = document.createElement("script"); script.src = src; script.async = false; script.setAttribute("data-etours-script", src); script.onload = function () { resolve(); }; script.onerror = function () { reject(new Error("Не удалось загрузить скрипт: " + src)); }; document.head.appendChild(script); }); } async function ensureDependencies(assetBase) { var hadEtoursJQ = !!(window.etoursJQ && window.etoursJQ.fn && window.etoursJQ.fn.dropdown && window.etoursJQ.fn.checkbox); if (hadEtoursJQ) { return; } var prevJQ = window.jQuery; var prevDollar = window.$; await loadScript(assetBase + "static/js/jquery-3.7.1.min.js"); await loadScript(assetBase + "static/js/core.js"); await loadScript(assetBase + "static/js/touch.js"); await loadScript(assetBase + "static/js/scrollbar.js"); await loadScript(assetBase + "static/js/dropdown.js"); await loadScript(assetBase + "static/js/checkbox.js"); await loadScript(assetBase + "static/js/calendar-availability.js"); if (window.jQuery && typeof window.jQuery.noConflict === "function") { window.etoursJQ = window.jQuery.noConflict(true); } if (prevJQ) { window.jQuery = prevJQ; } if (prevDollar) { window.$ = prevDollar; } } function buildWidgetMarkup(apiBaseUrl) { var safeApiBase = String(apiBaseUrl || "").replace(/"/g, """); return ( '
' + '
' + '
' + '
курс валюты
-
$-
-
' + '
Страна вылета
Город вылета
Страна тура
Тип тура
Название тура
' + '
Дата с
по
' + '
Ночей от
до
' + '
Взрослых
Детей
' + '
Возраст детей
,
' + '
' + '
Город/курорт
Звездность отеля
Питание
Отель
' + '
Доп. фильтры
' + '
Цена от
до
' + '
Показывать цену
' + '
' + "
" + '
Поиск завершен на 0% - подождите немного, мы подбираем для Вас лучшие цены...
' + "
" ); } var assetBase = resolveAssetBase(); var widgetVersionEarly = mountNode.getAttribute("data-widget-version") || "1.0.8"; ensureStyle(assetBase + "static/css/dropdown.css"); ensureStyle(assetBase + "static/css/checkbox.css"); ensureStyle(assetBase + "static/css/number.css"); ensureStyle(assetBase + "static/css/scrollbar.css"); ensureStyle(assetBase + "static/css/calendar-availability.css"); ensureStyle(assetBase + "static/datepicker.css"); ensureStyle(assetBase + "static/searchform.css"); ensureExtsearchCss(assetBase, widgetVersionEarly); await ensureDependencies(assetBase); document.body.classList.add("etours-extsearch"); var apiBase = mountNode.getAttribute("data-api-base") || DEFAULT_API_BASE; var agentEmail = mountNode.getAttribute("data-agent-email") || ""; var widgetName = mountNode.getAttribute("data-widget-name") || "etours-extsearch"; var widgetVersion = mountNode.getAttribute("data-widget-version") || "1.0.0"; var statHost = window.location && window.location.hostname ? String(window.location.hostname) : "localhost"; if (!statHost) { statHost = "localhost"; } var brandColor = mountNode.getAttribute("data-brand-color") || ""; var brandDark = mountNode.getAttribute("data-brand-dark") || darkenHexColor(brandColor, 0.2); var topColor = mountNode.getAttribute("data-top-color") || ""; var topDark = mountNode.getAttribute("data-top-dark") || darkenHexColor(topColor, 0.14); var textLight = mountNode.getAttribute("data-text-light") || ""; var buttonText = mountNode.getAttribute("data-button-text") || ""; var showCurrenciesRaw = String(mountNode.getAttribute("data-show-currencies") || "1").toLowerCase(); var showCurrencies = !(showCurrenciesRaw === "0" || showCurrenciesRaw === "false" || showCurrenciesRaw === "no" || showCurrenciesRaw === "off"); var formModeRaw = String(mountNode.getAttribute("data-form-mode") || "short").toLowerCase(); var defaultAdvancedOpen = formModeRaw === "full" || formModeRaw === "полная" || formModeRaw === "1" || formModeRaw === "true" || formModeRaw === "yes"; var roundedButtonsRaw = String(mountNode.getAttribute("data-rounded-buttons") || "0").toLowerCase(); var roundedButtons = roundedButtonsRaw === "1" || roundedButtonsRaw === "true" || roundedButtonsRaw === "yes" || roundedButtonsRaw === "on"; // Страны/города вылета: allowlist (выбрать). Пустой список = все. // Старые data-blacklist-departure-* ещё читаем как exclude для совместимости. var allowDepartureCountriesRaw = mountNode.getAttribute("data-allow-departure-countries") || mountNode.getAttribute("data-whitelist-departure-countries") || ""; var allowDepartureCitiesRaw = mountNode.getAttribute("data-allow-departure-cities") || mountNode.getAttribute("data-whitelist-departure-cities") || ""; var legacyBlacklistDepartureCountriesRaw = mountNode.getAttribute("data-blacklist-departure-countries") || ""; var legacyBlacklistDepartureCitiesRaw = mountNode.getAttribute("data-blacklist-departure-cities") || ""; var blacklistTourCountriesRaw = mountNode.getAttribute("data-blacklist-tour-countries") || ""; var defaultDepartureCountry = (mountNode.getAttribute("data-default-departure-country") || "").trim(); var defaultDepartureCity = (mountNode.getAttribute("data-default-departure-city") || "").trim(); var defaultTourCountry = (mountNode.getAttribute("data-default-tour-country") || "").trim(); mountNode.innerHTML = buildWidgetMarkup(apiBase); var wrapper = mountNode.querySelector(".sf-wrapper"); if (wrapper && !showCurrencies) { var currenciesBlock = wrapper.querySelector(".sf-currencies"); if (currenciesBlock) { currenciesBlock.style.display = "none"; currenciesBlock.setAttribute("hidden", "hidden"); } wrapper.classList.add("etw-no-currencies"); } if (wrapper && roundedButtons) { wrapper.classList.add("etw-rounded-buttons"); } if (wrapper) { if (brandColor) { wrapper.style.setProperty("--etw-color-brand", brandColor); } if (brandDark) { wrapper.style.setProperty("--etw-color-brand-dark", brandDark); } if (topColor) { wrapper.style.setProperty("--etw-color-top", topColor); } if (topDark) { wrapper.style.setProperty("--etw-color-top-dark", topDark); } if (textLight) { wrapper.style.setProperty("--etw-color-text-light", textLight); } if (buttonText) { wrapper.style.setProperty("--etw-color-button-text", buttonText); } } function setGlobalVar(name, value) { if (!value) { return; } document.documentElement.style.setProperty(name, value); if (document.body) { document.body.style.setProperty(name, value); } } setGlobalVar("--etw-color-brand", brandColor); setGlobalVar("--etw-color-brand-dark", brandDark); setGlobalVar("--etw-color-top", topColor); setGlobalVar("--etw-color-top-dark", topDark); setGlobalVar("--etw-color-text-light", textLight); setGlobalVar("--etw-color-button-text", buttonText); var q = function (selector) { return wrapper ? wrapper.querySelector(selector) : null; }; var form = q("#priceSearchForm"); if (!form) { throw new Error("Не удалось инициализировать форму поиска: #priceSearchForm не найден."); } var searchButton = form.querySelector(".sf-search-button"); var tableContainer = mountNode.querySelector(".sf-searchresult__table"); var loadingBox = mountNode.querySelector(".sf-searchresult__loading"); var progressNode = q("#sf-status__loader-bar-count"); var progressTextNode = q("#sf-status__loader-bar-text"); var countryFromNode = q("#countryfrom"); var depCityNode = q("#depCity"); var countryNode = q("#country"); var tourCityNode = q("#tourCity"); var tourTypeNode = q("#tourType"); var tourNode = q("#tour"); var mealNode = q("#meal"); var hotelStarsNode = q("#hotelStars"); var hotelSelectNode = q("#hotelSelect"); var hotelFilterNode = q("#hotelFilter"); var nightsFromNode = q("#nightsFrom"); var nightsToNode = q("#nightsTo"); var dateFromNode = q("#dateFrom"); var dateToNode = q("#dateTo"); var childsNode = q("#childs"); var child1AgeNode = q("#child1Age"); var child2AgeNode = q("#child2Age"); var childInBedNode = q("#childInBed"); var hasFlightSeatsNode = q("#hasFlightSeats"); var noStopSaleNode = q("#noStopSale"); var momentConfirmNode = q("#momentConfirm"); var advancedFiltersNode = q("#advancedFilters"); var advancedFiltersBox = q("#etwAdvancedFilters"); if (advancedFiltersNode) { advancedFiltersNode.checked = defaultAdvancedOpen; } if (advancedFiltersBox) { advancedFiltersBox.classList.toggle("sf-advanced-filters_hidden", !defaultAdvancedOpen); if (defaultAdvancedOpen) { advancedFiltersBox.removeAttribute("hidden"); } else { advancedFiltersBox.setAttribute("hidden", "hidden"); } } if (wrapper) { wrapper.classList.toggle("etw-form-short", !defaultAdvancedOpen); wrapper.classList.toggle("etw-form-full", defaultAdvancedOpen); } var adultsNode = q("#adults"); var priceFromNode = q("#priceFrom"); var priceToNode = q("#priceTo"); var priceModeNodes = form.querySelectorAll('input[name="ncr"]'); var jq = window.etoursJQ || null; var fullTownFroms = []; var countryFromMap = {}; var hotelCatalog = []; var tourTypeCatalog = []; var renderResultMap = {}; var pendingShareMultiSelect = null; var currentSearchPage = 1; var lastSearchPager = { current: 1, total: 1 }; var controlsRequestToken = 0; var lastCheckinList = null; var rateUsdNode = wrapper ? wrapper.querySelector("#rateUsd") : null; var rateEurNode = wrapper ? wrapper.querySelector("#rateEur") : null; var ratesDateNode = wrapper ? wrapper.querySelector("#ratesDate") : null; var dateHintsNode = wrapper ? wrapper.querySelector("#etwDateHints") : null; var nightsHintsNode = wrapper ? wrapper.querySelector("#etwNightsHints") : null; var calendarAvailability = null; function parseList(raw) { return String(raw || "") .split(",") .map(function (value) { return String(value || "").trim(); }) .filter(Boolean); } var allowDepartureCountries = parseList(allowDepartureCountriesRaw); var allowDepartureCities = parseList(allowDepartureCitiesRaw); var legacyBlacklistDepartureCountries = parseList(legacyBlacklistDepartureCountriesRaw); var legacyBlacklistDepartureCities = parseList(legacyBlacklistDepartureCitiesRaw); var blacklistTourCountries = parseList(blacklistTourCountriesRaw); // Пустой allow и нет старого blacklist → только Россия. if (!allowDepartureCountries.length && !legacyBlacklistDepartureCountries.length) { allowDepartureCountries = ["2"]; } function isDepartureCountryAllowed(countryId) { var id = String(countryId || ""); if (!id) { return false; } if (allowDepartureCountries.length) { return allowDepartureCountries.indexOf(id) >= 0; } if (legacyBlacklistDepartureCountries.length) { return legacyBlacklistDepartureCountries.indexOf(id) < 0; } return true; } function isDepartureCityAllowed(cityId, countryId) { var id = String(cityId || ""); if (!id || !isDepartureCountryAllowed(countryId)) { return false; } if (allowDepartureCities.length) { return allowDepartureCities.indexOf(id) >= 0; } // Город по умолчанию без списка «выбрать города» = только этот город. if (defaultDepartureCity) { return id === String(defaultDepartureCity); } if (legacyBlacklistDepartureCities.length) { return legacyBlacklistDepartureCities.indexOf(id) < 0; } return true; } if (window.EtwCalendarAvailability) { calendarAvailability = new window.EtwCalendarAvailability({ dateNode: dateHintsNode, nightsNode: nightsHintsNode, onRangeSelect: function (range) { var from = range && range.from ? String(range.from) : ""; var to = range && range.to ? String(range.to) : from; dateFromNode.value = from; dateToNode.value = to; hideDateHints(); refreshControls().catch(function () {}); } }); } function buildApiUrl(action, params) { var url = new URL(apiBase, window.location.href); url.searchParams.set("action", action); Object.keys(params || {}).forEach(function (key) { var value = params[key]; if (value !== undefined && value !== null && value !== "") { url.searchParams.set(key, String(value)); } }); return url.toString(); } async function fetchJSON(url) { var response = await fetch(url); var text = await response.text(); var payload = null; try { payload = JSON.parse(text); } catch (err) { throw new Error("API вернул не JSON"); } if (!response.ok || payload.ok === false) { throw new Error((payload && payload.message) || "Ошибка API"); } return payload; } function fillSelect(select, items, defaultLabel, valueKey, labelKey) { select.innerHTML = ""; if (defaultLabel) { var defaultOption = document.createElement("option"); defaultOption.value = ""; defaultOption.textContent = defaultLabel; select.appendChild(defaultOption); } items.forEach(function (item) { var option = document.createElement("option"); option.value = String(item[valueKey || "id"]); option.textContent = item[labelKey || "name"]; select.appendChild(option); }); } function getSelectedValues(selectNode) { return Array.prototype.slice .call(selectNode.options || []) .filter(function (opt) { return opt.selected && opt.value !== ""; }) .map(function (opt) { return String(opt.value); }); } function fillMultiSelect(selectNode, items, selectedValues, valueKey, labelKey) { var selectedMap = {}; (selectedValues || []).forEach(function (value) { selectedMap[String(value)] = true; }); var hasManualSelection = Object.keys(selectedMap).length > 0; selectNode.innerHTML = ""; (items || []).forEach(function (item) { var option = document.createElement("option"); option.value = String(item[valueKey || "id"]); option.textContent = item[labelKey || "name"]; option.selected = !!selectedMap[option.value] || (!hasManualSelection && Number(item.selected) === 1); selectNode.appendChild(option); }); } function updateFormstoneSelect(selectNode, hardReset) { if (selectNode && selectNode.getAttribute && selectNode.getAttribute("data-no-formstone") === "true") { return; } if (!jq || !jq.fn || !jq.fn.dropdown) { return; } var el = jq(selectNode); var isMultiple = !!(selectNode && selectNode.multiple); if (el.data("fsDropdown") && hardReset) { el.dropdown("destroy"); el.dropdown({ selectMultiple: isMultiple }); return; } if (el.data("fsDropdown")) { el.dropdown("update"); return; } el.dropdown({ selectMultiple: isMultiple }); } function refreshAllFormstoneSelects(hardReset) { if (!wrapper) { return; } wrapper.querySelectorAll(".sf-field__select").forEach(function (selectNode) { updateFormstoneSelect(selectNode, !!hardReset); }); } function isAdvancedFiltersOpen() { return !!(advancedFiltersNode && advancedFiltersNode.checked); } function setAdvancedFiltersVisible(isOpen) { var open = !!isOpen; if (advancedFiltersNode) { advancedFiltersNode.checked = open; if (jq && jq.fn && jq.fn.checkbox) { try { jq(advancedFiltersNode).checkbox("update"); } catch (err) {} } } if (advancedFiltersBox) { advancedFiltersBox.classList.toggle("sf-advanced-filters_hidden", !open); if (open) { advancedFiltersBox.removeAttribute("hidden"); } else { advancedFiltersBox.setAttribute("hidden", "hidden"); } } if (wrapper) { wrapper.classList.toggle("etw-form-short", !open); wrapper.classList.toggle("etw-form-full", open); } if (open) { refreshAllFormstoneSelects(false); } } function initFormstoneInputs() { if (!jq || !jq.fn) { return; } if (jq.fn.dropdown) { jq(".sf-field__select").each(function () { updateFormstoneSelect(this); }); } if (jq.fn.number) { jq(".sf-field__number").number(); } if (jq.fn.checkbox) { jq(".sf-field__checkbox, .sf-field__radio").checkbox(); } } function fillNightsOptions(nights, places) { nightsFromNode.innerHTML = ""; nightsToNode.innerHTML = ""; var placesMap = {}; (places || []).forEach(function (value) { placesMap[String(value)] = true; }); var hasPlaces = Object.keys(placesMap).length > 0; (nights || []).forEach(function (value) { var key = String(value); var isAvailable = hasPlaces ? !!placesMap[key] : true; var fromOption = document.createElement("option"); fromOption.value = key; fromOption.textContent = key + (isAvailable ? "" : " (по запросу)"); fromOption.disabled = !isAvailable; nightsFromNode.appendChild(fromOption); var toOption = document.createElement("option"); toOption.value = key; toOption.textContent = key + (isAvailable ? "" : " (по запросу)"); toOption.disabled = !isAvailable; nightsToNode.appendChild(toOption); }); } function fillNightsFromObject(nightsObject) { var nights = []; var places = []; var defaultFrom = ""; var defaultTill = ""; if (Array.isArray(nightsObject)) { nights = nightsObject; } else if (nightsObject && Array.isArray(nightsObject.nights)) { nights = nightsObject.nights; places = Array.isArray(nightsObject.places) ? nightsObject.places : []; defaultFrom = nightsObject.default && nightsObject.default.from ? String(nightsObject.default.from) : ""; defaultTill = nightsObject.default && nightsObject.default.till ? String(nightsObject.default.till) : ""; } fillNightsOptions(nights, places); if (defaultFrom) { nightsFromNode.value = defaultFrom; } if (defaultTill) { nightsToNode.value = defaultTill; } if ((!nightsFromNode.value || (nightsFromNode.selectedOptions[0] && nightsFromNode.selectedOptions[0].disabled)) && nightsFromNode.options.length) { var firstEnabledFrom = Array.prototype.find.call(nightsFromNode.options, function (opt) { return !opt.disabled; }); if (firstEnabledFrom) { nightsFromNode.value = String(firstEnabledFrom.value); } else { nightsFromNode.selectedIndex = 0; } } if ((!nightsToNode.value || (nightsToNode.selectedOptions[0] && nightsToNode.selectedOptions[0].disabled)) && nightsToNode.options.length) { var enabledTo = Array.prototype.filter.call(nightsToNode.options, function (opt) { return !opt.disabled; }); if (enabledTo.length) { nightsToNode.value = String(enabledTo[enabledTo.length - 1].value); } else { nightsToNode.selectedIndex = nightsToNode.options.length - 1; } } if (calendarAvailability) { calendarAvailability.updateNights(nights, places); } } function getSelectFieldRoot(selectNode) { if (!selectNode) { return null; } if (selectNode.closest) { var byClosest = selectNode.closest(".sf-field"); if (byClosest) { return byClosest; } } var node = selectNode.parentNode; while (node && node !== wrapper && node !== document.body) { if (node.classList && node.classList.contains("sf-field")) { return node; } node = node.parentNode; } return null; } function setSelectFieldCaption(selectNode, text) { var root = getSelectFieldRoot(selectNode); if (!root) { return; } var caption = root.querySelector(".sf-field__caption"); if (caption) { caption.textContent = text; } } function setSelectFieldVisible(selectNode, visible) { var root = getSelectFieldRoot(selectNode); if (!root) { return; } root.classList.toggle("is-auto-hidden", !visible); } function getSelectedOptionLabel(selectNode) { if (!selectNode || selectNode.selectedIndex < 0) { return ""; } var opt = selectNode.options[selectNode.selectedIndex]; return opt && opt.value ? String(opt.textContent || "").trim() : ""; } function selectHasOption(selectNode, value) { var id = String(value || ""); if (!selectNode || !id) { return false; } return Array.prototype.some.call(selectNode.options || [], function (opt) { return String(opt.value || "") === id; }); } function countValuedOptions(selectNode) { if (!selectNode) { return 0; } return Array.prototype.slice.call(selectNode.options || []).filter(function (opt) { return String(opt.value || "") !== ""; }).length; } function ensureSoleValuedOptionSelected(selectNode) { if (!selectNode) { return ""; } var valued = Array.prototype.slice.call(selectNode.options || []).filter(function (opt) { return String(opt.value || "") !== ""; }); if (valued.length !== 1) { return selectNode.value ? String(selectNode.value) : ""; } var onlyId = String(valued[0].value); if (String(selectNode.value || "") !== onlyId) { selectNode.value = onlyId; } return onlyId; } /** * Formstone может показывать подпись города при пустом native value * (если value выставили на id, которого нет в options — браузер сбрасывает в ""). * Перед search/controls всегда нормализуем TOWNFROMINC. */ function ensureDepartureCitySelected() { if (countryFromNode) { if (!countryFromNode.value) { ensureSoleValuedOptionSelected(countryFromNode); } if (!countryFromNode.value) { var preferredCountry = resolveDefaultDepartureCountryId( Object.keys(countryFromMap || {}).map(function (key) { return countryFromMap[key]; }) ); if (preferredCountry && selectHasOption(countryFromNode, preferredCountry)) { countryFromNode.value = preferredCountry; filterTownFromsByCountryFrom(); } } } if (!depCityNode) { return ""; } var current = String(depCityNode.value || ""); if (current && selectHasOption(depCityNode, current)) { return current; } var nextId = ""; if (defaultDepartureCity && selectHasOption(depCityNode, defaultDepartureCity)) { nextId = String(defaultDepartureCity); } else { var valued = Array.prototype.slice.call(depCityNode.options || []).filter(function (opt) { return String(opt.value || "") !== ""; }); if (valued.length === 1) { nextId = String(valued[0].value); } else if (valued.length > 0) { nextId = String(valued[0].value); } } if (nextId) { depCityNode.value = nextId; updateFormstoneSelect(depCityNode, true); syncDepartureFieldPresentation(); } return String(depCityNode.value || ""); } var lastDepartureCountryCount = 0; var lastDepartureCityCount = 0; function syncDepartureFieldPresentation() { var countryCount = lastDepartureCountryCount || countValuedOptions(countryFromNode); var cityCount = lastDepartureCityCount || countValuedOptions(depCityNode); if (countryCount === 1) { ensureSoleValuedOptionSelected(countryFromNode); } if (cityCount === 1) { ensureSoleValuedOptionSelected(depCityNode); } var countryName = getSelectedOptionLabel(countryFromNode) || (countryFromNode.value && countryFromMap[String(countryFromNode.value)] ? countryFromMap[String(countryFromNode.value)].name : ""); var cityName = getSelectedOptionLabel(depCityNode); var hideCountry = countryCount === 1 && !!countryFromNode.value; var hideCity = cityCount === 1 && !!depCityNode.value; setSelectFieldVisible(countryFromNode, !hideCountry); setSelectFieldCaption(countryFromNode, "Страна вылета"); setSelectFieldVisible(depCityNode, !hideCity); if (hideCountry && countryName) { setSelectFieldCaption(depCityNode, "Город вылета (" + countryName + ")"); } else { setSelectFieldCaption(depCityNode, "Город вылета"); } if (hideCity) { var tourCaption = "Страна тура"; if (countryName && cityName) { tourCaption = "Страна тура (" + countryName + ", из " + cityName + ")"; } else if (cityName) { tourCaption = "Страна тура (из " + cityName + ")"; } else if (countryName) { tourCaption = "Страна тура (" + countryName + ")"; } setSelectFieldCaption(countryNode, tourCaption); } else { setSelectFieldCaption(countryNode, "Страна тура"); } } function buildCountryFromOptions(townFroms) { var map = {}; (townFroms || []).forEach(function (town) { var key = String(town.stateFromKey || ""); if (!key || !isDepartureCountryAllowed(key)) { return; } if (!map[key]) { map[key] = { id: key, name: town.stateFromName || ("Страна " + key) }; } }); countryFromMap = map; var items = Object.keys(map).map(function (key) { return map[key]; }); items.sort(function (a, b) { return a.name.localeCompare(b.name, "ru"); }); lastDepartureCountryCount = items.length; // Одна страна — без «Любая», сразу выбираем её. fillSelect(countryFromNode, items, items.length === 1 ? "" : "Любая"); if (items.length === 1) { countryFromNode.value = String(items[0].id); } else { var preferredCountryFrom = resolveDefaultDepartureCountryId(items); if (preferredCountryFrom) { countryFromNode.value = String(preferredCountryFrom); } } updateFormstoneSelect(countryFromNode, true); if (items.length === 1) { ensureSoleValuedOptionSelected(countryFromNode); updateFormstoneSelect(countryFromNode, false); } } function resolveDefaultDepartureCountryId(items) { var list = items || []; if (defaultDepartureCountry) { var byAttr = list.find(function (item) { return String(item.id) === String(defaultDepartureCountry); }); if (byAttr) { return String(byAttr.id); } } var byName = list.find(function (item) { var name = String(item.name || "").toLowerCase(); return name === "россия" || name === "russia"; }); if (byName) { return String(byName.id); } var byKnownId = list.find(function (item) { return String(item.id) === "2"; }); return byKnownId ? String(byKnownId.id) : ""; } function filterTownFromsByCountryFrom() { var selectedCountryFrom = countryFromNode.value; var currentTown = depCityNode.value; var filtered = fullTownFroms.filter(function (town) { var townId = String(town.id || ""); var countryId = String(town.stateFromKey || ""); if (!isDepartureCityAllowed(townId, countryId)) { return false; } return !selectedCountryFrom || countryId === String(selectedCountryFrom); }); fillSelect(depCityNode, filtered, filtered.length === 1 ? "" : "Любой"); lastDepartureCityCount = filtered.length; if (filtered.length === 1) { depCityNode.value = String(filtered[0].id); } else { if (currentTown) { var existsInFiltered = filtered.some(function (town) { return String(town.id) === String(currentTown); }); if (existsInFiltered) { depCityNode.value = currentTown; } } if (!depCityNode.value && filtered.length > 0) { if (defaultDepartureCity) { var defaultTown = filtered.find(function (town) { return String(town.id) === String(defaultDepartureCity); }); if (defaultTown) { depCityNode.value = String(defaultTown.id); } } } if (!depCityNode.value && filtered.length > 0) { var selectedTown = filtered.find(function (town) { return Number(town.selected) === 1; }); depCityNode.value = String((selectedTown || filtered[0]).id); } } updateFormstoneSelect(depCityNode, true); if (filtered.length === 1) { ensureSoleValuedOptionSelected(depCityNode); updateFormstoneSelect(depCityNode, false); } syncDepartureFieldPresentation(); } function bindSelectChange(node, handler) { if (!node) { return; } node.addEventListener("change", handler); if (jq && jq.fn) { jq(node).on("change.etours", handler); } } function handleCascadeSelectChange(node) { if (!node) { return; } if (node === countryFromNode) { hideDateHints(); filterTownFromsByCountryFrom(); refreshControls({ resetHotels: true }).catch(function () {}); return; } if (node === depCityNode || node === countryNode || node === tourCityNode || node === tourTypeNode || node === tourNode || node === mealNode || node === hotelStarsNode) { hideDateHints(); refreshControls({ resetHotels: true }).catch(function () {}); return; } if (node === hotelSelectNode) { hideDateHints(); } } function bindCascadeSelectHandlers() { if (!wrapper || wrapper.__etwCascadeBound) { return; } wrapper.__etwCascadeBound = true; wrapper.addEventListener("change", function (event) { var node = event.target; if (!node || !node.classList || !node.classList.contains("sf-field__select")) { return; } handleCascadeSelectChange(node); }); if (jq && jq.fn) { jq(wrapper).on("change.etours", ".sf-field__select", function () { handleCascadeSelectChange(this); }); } } function filterHotelOptions() { rebuildHotelSelect(); } function filterHotelsBySelections(items) { var selectedTowns = getSelectedValues(tourCityNode); var selectedStars = getSelectedValues(hotelStarsNode); var pattern = String(hotelFilterNode.value || "").trim().toLowerCase(); return (items || []).filter(function (hotel) { var townOk = selectedTowns.length === 0 || selectedTowns.indexOf(String(hotel.townKey || "")) >= 0; var starOk = selectedStars.length === 0 || selectedStars.indexOf(String(hotel.starKey || "")) >= 0; var nameOk = !pattern || String(hotel.name || "").toLowerCase().indexOf(pattern) >= 0; return townOk && starOk && nameOk; }); } function rebuildHotelSelect(selectedHotelIds) { var selected = selectedHotelIds || getSelectedValues(hotelSelectNode); var visibleHotels = filterHotelsBySelections(hotelCatalog); var hotelsWithStars = visibleHotels.map(function (item) { var starLabel = item.star || item.starAlt || ""; // In SAMO Search_Api::_getPRICES() the booking token is returned as: // id = Cat_Claim. Keep mapping strict to avoid passing wrong identifiers. var catClaimValue = item.id || item.Key || ""; return { id: item.id, name: starLabel ? item.name + ", " + starLabel : item.name }; }); fillMultiSelect(hotelSelectNode, hotelsWithStars, selected); updateFormstoneSelect(hotelSelectNode, true); } function clearSelectSelection(selectNode) { Array.prototype.slice.call(selectNode.options || []).forEach(function (option) { option.selected = false; }); } function filterToursBySelectedType(tours) { var selectedTypeId = String(tourTypeNode.value || ""); if (!selectedTypeId) { return tours || []; } var selectedType = (tourTypeCatalog || []).find(function (type) { return String(type.id) === selectedTypeId; }); if (!selectedType || !Array.isArray(selectedType.tours) || selectedType.tours.length === 0) { return tours || []; } var allowed = {}; selectedType.tours.forEach(function (tourId) { allowed[String(tourId)] = true; }); return (tours || []).filter(function (tour) { return !!allowed[String(tour.id)]; }); } function updateChildAgeState() { var childrenCount = Number(childsNode.value || 0); var child1Enabled = childrenCount >= 1; var child2Enabled = childrenCount >= 2; child1AgeNode.disabled = !child1Enabled; child2AgeNode.disabled = !child2Enabled; if (!child1Enabled) { child1AgeNode.value = "0"; } if (!child2Enabled) { child2AgeNode.value = "0"; } updateFormstoneSelect(child1AgeNode, true); updateFormstoneSelect(child2AgeNode, true); } async function refreshControls(options) { var opts = options || {}; var requestToken = ++controlsRequestToken; var controls = "STATES,TOWNS,TOUR_TYPES,TOURS,MEALS,STARS,HOTELS,NIGHTS,CHECKIN_LIST"; var selectedTowns = isAdvancedFiltersOpen() ? getSelectedValues(tourCityNode) : []; var selectedStars = isAdvancedFiltersOpen() ? getSelectedValues(hotelStarsNode) : []; var selectedHotels = opts.resetHotels || !isAdvancedFiltersOpen() ? [] : getSelectedValues(hotelSelectNode); var selectedMeal = isAdvancedFiltersOpen() ? getSelectedValues(mealNode) : []; if (opts.resetHotels) { clearSelectSelection(hotelSelectNode); hotelFilterNode.value = ""; } var params = { controls: controls, TOWNFROMINC: ensureDepartureCitySelected() || "", STATEINC: countryNode.value || "", TOWNS: selectedTowns.join(","), TOURTYPEINC: tourTypeNode.value || "", TOURINC: tourNode.value || "", MEALS: selectedMeal.join(","), STARS: selectedStars.join(","), HOTELS: selectedHotels.join(","), CHECKIN_BEG: toIsoDate(dateFromNode.value), CHECKIN_END: toIsoDate(dateToNode.value), NIGHTS_FROM: nightsFromNode.value || "", NIGHTS_TILL: nightsToNode.value || "" }; var payload = await fetchJSON(buildApiUrl("controls", params)); if (requestToken !== controlsRequestToken) { return; } var data = payload.data || {}; var nextControls = data.controls || {}; var selectedCountry = countryNode.value; var selectedTourCities = selectedTowns; var selectedTourType = tourTypeNode.value; var selectedTour = tourNode.value; var requestedStateInc = String(params.STATEINC || ""); if (Array.isArray(nextControls.STATES)) { var allowedStates = nextControls.STATES.filter(function (item) { return blacklistTourCountries.indexOf(String(item.id || "")) < 0; }); fillSelect(countryNode, allowedStates, "Любая"); if (selectedCountry && selectHasOption(countryNode, selectedCountry)) { countryNode.value = selectedCountry; } if (!countryNode.value && defaultTourCountry) { var hasDefaultTourCountry = allowedStates.some(function (item) { return String(item.id) === String(defaultTourCountry); }); if (hasDefaultTourCountry) { countryNode.value = String(defaultTourCountry); } } if (!countryNode.value) { var selectedState = allowedStates.find(function (item) { return Number(item.selected) === 1; }); if (selectedState) { countryNode.value = String(selectedState.id); } else if (allowedStates.length > 0 && !defaultTourCountry) { countryNode.value = String(allowedStates[0].id); } } updateFormstoneSelect(countryNode, true); } if (Array.isArray(nextControls.TOUR_TYPES)) { tourTypeCatalog = nextControls.TOUR_TYPES; fillSelect(tourTypeNode, nextControls.TOUR_TYPES, "Любой"); if (selectedTourType && selectHasOption(tourTypeNode, selectedTourType)) { tourTypeNode.value = selectedTourType; } updateFormstoneSelect(tourTypeNode, true); } if (Array.isArray(nextControls.TOURS)) { var filteredTours = filterToursBySelectedType(nextControls.TOURS); fillSelect(tourNode, filteredTours, "Любой"); if (selectedTour && selectHasOption(tourNode, selectedTour)) { tourNode.value = selectedTour; } updateFormstoneSelect(tourNode, true); } if (Array.isArray(nextControls.MEALS)) { fillMultiSelect(mealNode, nextControls.MEALS, selectedMeal); updateFormstoneSelect(mealNode, true); } if (Array.isArray(nextControls.TOWNS)) { fillMultiSelect(tourCityNode, nextControls.TOWNS, selectedTourCities); updateFormstoneSelect(tourCityNode, true); } if (Array.isArray(nextControls.STARS)) { var filteredStars = nextControls.STARS.filter(function (item) { var match = String(item.name || item.nameAlt || "").match(/(\d+)\s*\*/); if (!match) { return false; } return Number(match[1]) >= 2; }); fillMultiSelect(hotelStarsNode, filteredStars, selectedStars); updateFormstoneSelect(hotelStarsNode, true); } if (Array.isArray(nextControls.HOTELS)) { hotelCatalog = nextControls.HOTELS.map(function (item) { return { id: item.id, name: item.name || "", star: item.star || item.starAlt || "", starAlt: item.starAlt || item.star || "", starKey: item.starKey || "", townKey: item.townKey || "" }; }); rebuildHotelSelect(selectedHotels); } else if (hotelCatalog.length) { rebuildHotelSelect(selectedHotels); } if (nextControls.NIGHTS && typeof nextControls.NIGHTS === "object" && !nextControls.NIGHTS.error) { fillNightsFromObject(nextControls.NIGHTS); updateFormstoneSelect(nightsFromNode, true); updateFormstoneSelect(nightsToNode, true); } if (nextControls.CHECKIN_LIST && calendarAvailability) { var checkinPayload = nextControls.CHECKIN_LIST; // SAMO отдаёт объект { date: { valid, start }, value, rangeCalendar }. if (Array.isArray(checkinPayload)) { checkinPayload = checkinPayload.length ? checkinPayload[0] : null; } if (checkinPayload && checkinPayload.date && (checkinPayload.date.valid || checkinPayload.date.start)) { lastCheckinList = checkinPayload; calendarAvailability.updateDates(checkinPayload); calendarAvailability.setRange(dateFromNode.value, dateToNode.value); } } // После автовыбора страны (default / selected / первая) списки типа тура, // туров, городов и т.д. нужно перезапросить уже с STATEINC. var finalStateInc = String(countryNode.value || ""); if (finalStateInc && finalStateInc !== requestedStateInc) { await refreshControls(opts); return; } refreshAllFormstoneSelects(false); syncDepartureFieldPresentation(); } function normalizePrices(payload) { var root = payload && payload.data ? payload.data : {}; var prices = root.result && Array.isArray(root.result.prices) ? root.result.prices : []; return prices.map(function (item) { var isStopSale = Boolean(item.stopSale || item.stopSpog); var isMomentConfirm = Boolean(item.momentConfirm || item.MomentConfirm || item.confirmNow || item.ConfirmNow); var flightInCode = item.freights && item.freights.econom ? String(item.freights.econom.in || "") : ""; var flightOutCode = item.freights && item.freights.econom ? String(item.freights.econom.out || "") : ""; var hotelAvailabilityCode = String(item.hotelAvailability || ""); var baggage = item.freights && item.freights.econom ? String(item.freights.econom.baggage || "") : ""; var spoRaw = item.spo || item.SPO || ""; var priceRub = item.convertedPriceNumber || item.convertedPrice || item.price || ""; var priceTour = item.price || item.PriceCur || item.convertedPrice || ""; var tourCurrency = item.currencyAlias || item.currency || item.Currency || ""; var priceRubNumber = Number(item.convertedPriceNumber || 0); var priceTourNumber = Number(item.price || 0); var programLink = item.programLink || item.tourUrl || item.url || item.packetUrl || item.link || ""; var catClaimValue = item.id || item.Key || ""; return { resultId: item.id || item.Key || "", nights: item.nights || item.hnights || "", country: item.state || item.country || "", city: item.town || item.city || "", departureCity: item.townFrom || "", hotel: item.hotel || "", star: item.star || item.starAlt || "", room: item.room || "", meal: item.meal || "", tour: item.tour || item.title || "", spoRaw: spoRaw, hotelUrl: item.hotelUrl || item.HotelUrl || "", dateFrom: item.checkIn || item.dateFrom || "", dateTo: item.checkOut || item.dateTo || "", dirDate: item.DirDate || item.checkIn || item.dateFrom || "", dirWeekDay: item.DirWeekDay || "", dirTimeFrom: item.DirTimeFrom || "", dirTimeTo: item.DirTimeTo || "", bakDate: item.BakDate || item.checkOut || item.dateTo || "", bakWeekDay: item.BakWeekDay || "", bakTimeFrom: item.BakTimeFrom || "", bakTimeTo: item.BakTimeTo || "", priceRub: priceRub, priceRubNumber: Number.isFinite(priceRubNumber) ? priceRubNumber : 0, priceTour: priceTour, priceTourNumber: Number.isFinite(priceTourNumber) ? priceTourNumber : 0, tourCurrency: tourCurrency, link: item.bronUrl || item.packetUrl || item.url || "", programLink: programLink, catClaim: catClaimValue, flightInCode: flightInCode, flightOutCode: flightOutCode, baggage: baggage, hotelAvailabilityCode: hotelAvailabilityCode, stopSale: isStopSale, momentConfirm: isMomentConfirm }; }); } function applyPriceRangeFilter(items, priceFrom, priceTo, priceMode) { var min = Number(priceFrom || 0); var max = Number(priceTo || 0); var hasMin = Number.isFinite(min) && min > 0; var hasMax = Number.isFinite(max) && max > 0; if (!hasMin && !hasMax) { return items; } return items.filter(function (item) { var value = priceMode === "rub" ? Number(item.priceRubNumber || 0) : Number(item.priceTourNumber || 0); if (!Number.isFinite(value) || value <= 0) { return false; } if (hasMin && value < min) { return false; } if (hasMax && value > max) { return false; } return true; }); } function stripHtml(value) { return String(value || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); } function hasHtmlTags(value) { return /<[^>]+>/.test(String(value || "")); } function decodeAvailabilityCode(code) { var normalized = String(code || "").trim().toUpperCase(); if (!normalized) { return { text: "по запросу", level: "info" }; } if (normalized === "Y" || normalized === "A" || normalized === "OK") { return { text: "есть", level: "ok" }; } if (normalized === "L" || normalized === "M") { return { text: "мало мест", level: "warn" }; } if (normalized === "N" || normalized === "X" || normalized === "S") { return { text: "нет мест", level: "error" }; } if (normalized === "R" || normalized === "Q" || normalized === "P") { return { text: "по запросу", level: "info" }; } return { text: "по запросу", level: "info" }; } function decodeHotelAvailability(raw) { var text = String(raw || "").trim().toUpperCase(); if (!text) { return { text: "по запросу", level: "info" }; } // SAMO often returns per-segment codes, e.g. "RRRR". Use the worst status. if (text.indexOf("N") >= 0 || text.indexOf("X") >= 0 || text.indexOf("S") >= 0) { return { text: "нет", level: "error" }; } if (text.indexOf("L") >= 0 || text.indexOf("M") >= 0) { return { text: "мало", level: "warn" }; } if (text.indexOf("Y") >= 0 || text.indexOf("A") >= 0) { return { text: "есть", level: "ok" }; } return { text: "по запросу", level: "info" }; } function normalizeWeekDayShort(value) { var map = { "1": "пн", "2": "вт", "3": "ср", "4": "чт", "5": "пт", "6": "сб", "7": "вс" }; return map[String(value)] || ""; } function formatTimeRange(from, to) { var fromText = String(from || "").slice(0, 5); var toText = String(to || "").slice(0, 5); if (fromText && toText) { return fromText + "-" + toText; } return fromText || toText || ""; } function formatFlightCell(dateText, weekDayValue, timeFrom, timeTo) { var dateLine = dateText || "-"; var weekDay = normalizeWeekDayShort(weekDayValue); var timeLine = formatTimeRange(timeFrom, timeTo); var secondLine = [weekDay, timeLine].filter(Boolean).join(" "); if (!secondLine) { return dateLine; } return dateLine + "
" + secondLine + ""; } function getPriceMode() { var checked = form.querySelector('input[name="ncr"]:checked'); return checked ? checked.value : "tour"; } function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function buildRouteLabel(routeText, context, alt) { var explicit = String(routeText || "").trim(); if (explicit) { return explicit; } var from = context && context.departureCity ? String(context.departureCity) : ""; var toParts = [context && context.city ? String(context.city) : "", context && context.country ? String(context.country) : ""].filter(Boolean); var to = toParts.join(", "); var codes = [(alt && alt.flightInCode) || "", (alt && alt.flightOutCode) || ""].filter(Boolean).join(" / "); var base = [from, to].filter(Boolean).join(" - "); if (base && codes) { return base + " (" + codes + ")"; } return base || codes || "маршрут уточняется"; } function toNumberSafe(value) { var num = Number(value); return Number.isFinite(num) ? num : null; } function formatMinutesRu(minutes) { var total = toNumberSafe(minutes); if (total === null || total < 0) { return ""; } var h = Math.floor(total / 60); var m = Math.floor(total % 60); if (h && m) { return h + " ч " + m + " м"; } if (h) { return h + " ч"; } return m + " м"; } function normalizeStopsLabel(stops, segments) { var parsed = toNumberSafe(stops); // Не использовать segments.length - 1: для RT два сегмента ≠ одна пересадка. if (parsed === null && Array.isArray(segments) && segments.length > 0) { parsed = countOfferStops(segments); } if (parsed === null) { return "пересадки: уточняется"; } if (parsed <= 0) { return "без пересадок"; } return parsed + " пересад."; } function parseSegmentsSafe(rawEncoded) { if (!rawEncoded) { return []; } try { var decoded = decodeURIComponent(String(rawEncoded)); var parsed = JSON.parse(decoded); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } } function hasBaggageOption(value) { var txt = String(value || "").toLowerCase().trim(); if (!txt || txt === "-" || txt.indexOf("уточ") >= 0) { return null; } if (txt.indexOf("нет") >= 0 || txt === "0pc" || txt === "0p" || txt === "no") { return false; } return true; } function formatBaggageLabel(value) { var txt = String(value || "").trim(); if (!txt || /^уточняется$/i.test(txt) || /^n\/?a$/i.test(txt) || txt === "-" || txt === "—") { return "ручная кладь"; } return txt; } function formatBaggagePrefLabel(value) { if (value === "without") { return "Только без багажа"; } if (value === "all") { return "Не важно"; } return "Только с багажом"; } function formatSegmentsText(segments) { if (!Array.isArray(segments) || !segments.length) { return ""; } return segments.map(function (seg, idx) { var route = [seg.from || "", seg.to || ""].filter(Boolean).join(" - "); var carrier = [seg.carrier || "", seg.flightNumber || ""].filter(Boolean).join(" "); var times = [seg.departure || "", seg.arrival || ""].filter(Boolean).join(" - "); return (idx + 1) + ") " + [route, times, carrier].filter(Boolean).join(" | "); }).join("; "); } function applyFlightCalcToContext(context, flightCalc, selected) { var source = flightCalc || selected || null; if (!source) { return; } if (flightCalc) { context.flightAvailable = !!flightCalc.available; context.baggage = flightCalc.baggage || context.baggage || ""; context.route = flightCalc.route || context.route || ""; context.flightInCode = flightCalc.flightInCode || context.flightInCode || ""; context.flightOutCode = flightCalc.flightOutCode || context.flightOutCode || ""; context.flightDuration = flightCalc.durationText || context.flightDuration || ""; context.flightStops = flightCalc.stops || context.flightStops || ""; context.offerGuid = flightCalc.offerGuid || context.offerGuid || ""; context.flightBasePrice = flightCalc.basePrice != null ? String(flightCalc.basePrice) : (context.flightBasePrice || ""); context.flightDelta = flightCalc.deltaPrice != null && Number(flightCalc.deltaPrice) !== 0 ? String(flightCalc.deltaPrice) : "0"; context.flightAlternativesCount = flightCalc.alternativesCount != null ? String(flightCalc.alternativesCount) : ""; var segments = flightCalc.segments || []; context.flightSegmentsJson = JSON.stringify(segments); context.flightSegmentsText = formatSegmentsText(segments); } else if (selected) { context.route = buildRouteLabel(selected.route, context, selected); context.flightInCode = selected.flightInCode || context.flightInCode || ""; context.flightOutCode = selected.flightOutCode || context.flightOutCode || ""; context.baggage = selected.baggage || context.baggage || ""; context.flightDuration = selected.durationText || formatMinutesRu(selected.durationMinutes) || context.flightDuration || ""; context.flightStops = normalizeStopsLabel(selected.stops, selected.segments) || context.flightStops || ""; context.offerGuid = selected.offer_guid || context.offerGuid || ""; context.flightBasePrice = selected.basePrice != null ? String(selected.basePrice) : context.flightBasePrice || ""; context.flightDelta = selected.deltaPrice != null && Number(selected.deltaPrice) !== 0 ? String(selected.deltaPrice) : "0"; var selectedSegments = Array.isArray(selected.segments) ? selected.segments : []; context.flightSegmentsJson = JSON.stringify(selectedSegments); context.flightSegmentsText = formatSegmentsText(selectedSegments); } // Пересобираем маршрут по ориентированным ногам (VVO→SVX / SVX→VVO), // чтобы не показывать SVX-SVX и перепутанные направления. try { var segsForRoute = context.flightSegmentsJson ? JSON.parse(context.flightSegmentsJson) : []; if (Array.isArray(segsForRoute) && segsForRoute.length) { var oriented = splitSegmentsByDirection(segsForRoute, context); var rebuilt = buildRouteFromLegs(oriented.outbound, oriented.inbound); if (rebuilt) { context.route = rebuilt; } var outDur = formatMinutesRu(sumSegmentsDurationMinutes(oriented.outbound)); var inDur = formatMinutesRu(sumSegmentsDurationMinutes(oriented.inbound)); if (outDur || inDur) { context.flightDuration = [outDur ? "туда " + outDur : "", inDur ? "обратно " + inDur : ""].filter(Boolean).join(", "); } context.flightStops = [ oriented.outbound.length ? "туда: " + normalizeStopsLabel(Math.max(oriented.outbound.length - 1, 0), oriented.outbound) : "", oriented.inbound.length ? "обратно: " + normalizeStopsLabel(Math.max(oriented.inbound.length - 1, 0), oriented.inbound) : "" ] .filter(Boolean) .join(", ") || context.flightStops; } } catch (routeErr) { // keep previous route } context.baggageLabel = formatBaggageLabel(context.baggage); context.baggageRequired = "all"; context.baggageRequiredLabel = "Не важно"; } function updateFlightChangeAvailability(context, alternatives, meta) { meta = meta || {}; var alts = Array.isArray(alternatives) ? alternatives : []; context._flightAlternatives = alts; if (alts.length > 1) { context.flightChangeAvailable = true; context.flightChangeNote = ""; return; } context.flightChangeAvailable = false; if (meta.loading) { context.flightChangeNote = "Ищем варианты перелета…"; return; } if (meta.failed) { var failMsg = meta.message ? String(meta.message).replace(/\s+/g, " ").trim() : ""; if (failMsg.length > 160) { failMsg = failMsg.slice(0, 157) + "…"; } context.flightChangeNote = failMsg ? ("Смена перелета недоступна: " + failMsg) : "Смена перелета недоступна — не удалось получить варианты. Агент подберёт при заявке."; return; } if (meta.ok) { context.flightChangeNote = alts.length === 1 ? "Других вариантов перелета сейчас нет" : "Варианты для смены перелета не найдены"; return; } context.flightChangeNote = "Смена перелета будет доступна после уточнения рейса"; } function renderFlightSummaryBlock(context) { var canChangeFlight = !!context.flightChangeAvailable || (Array.isArray(context._flightAlternatives) && context._flightAlternatives.length > 1); if (!context.route && !context.baggage && !context.flightDuration && !canChangeFlight && !context.flightChangeNote) { return '
Данные перелета появятся после актуализации.
'; } var segments = []; try { segments = context.flightSegmentsJson ? JSON.parse(context.flightSegmentsJson) : []; } catch (e) { segments = []; } var changeAction = canChangeFlight ? '' : (context.flightChangeNote ? '
' + escapeHtml(context.flightChangeNote) + "
" : ""); return ( '
' + '
' + '
Авиабилеты и багаж
' + (changeAction ? '
' + changeAction + "
" : "") + "
" + '
' + '
Маршрут: ' + escapeHtml(context.route || "-") + "
" + (Number(context.flightDelta) !== 0 ? '
Доплата за перелет: ' + escapeHtml(formatFlightDeltaLabel(context.flightDelta, context.tourCurrency || "")) + "
" : "") + "
" + '
Багаж: ' + escapeHtml(context.baggageLabel || formatBaggageLabel(context.baggage)) + "
" + (segments.length ? '
' + renderSegmentsStructured(segments, context) + "
" : "") + "
" ); } function bindFlightChangeButton(body, context) { if (!body) { return; } var btn = body.querySelector(".etw-flight-summary-wrap .etw-flight-open-modal"); if (!btn) { return; } btn.onclick = function () { if (!Array.isArray(context._flightAlternatives) || context._flightAlternatives.length < 2) { return; } openFlightAlternativesModal(context._flightAlternatives, context, body, context._rerunActualize); }; } function syncFlightSummaryUi(body, context) { var holder = body.querySelector(".etw-flight-summary-wrap"); if (holder) { holder.innerHTML = renderFlightSummaryBlock(context); bindFlightChangeButton(body, context); } var routeNode = body.querySelector(".etw-meta-route"); if (routeNode) { routeNode.textContent = context.route || "-"; } var baggageNode = body.querySelector(".etw-meta-baggage"); if (baggageNode) { baggageNode.textContent = context.baggageLabel || formatBaggageLabel(context.baggage) || "-"; } var flightCodesNode = body.querySelector(".etw-meta-flight-codes"); if (flightCodesNode) { flightCodesNode.textContent = (context.flightInCode || "-") + " / " + (context.flightOutCode || "-"); } } function formatMoney(amount, currency) { var n = Number(amount); if (!isFinite(n)) { return "-"; } var rounded = Math.round(n * 100) / 100; return String(rounded) + (currency ? " " + currency : ""); } function buildActualizeApiParams(context, item, options) { options = options || {}; var offerGuid = ""; if (!options.skipOfferGuid) { offerGuid = context.offerGuid || ""; } else if (options.offerGuidHint) { offerGuid = String(options.offerGuidHint); } return { resultId: item.resultId || "", catClaim: item.catClaim || context.catClaim || "", BAGGAGE_REQUIRED: "all", WITH_INSURANCE: context.withInsurance ? "1" : "0", WITH_VISA: context.withVisa ? "1" : "0", OFFER_GUID: offerGuid, TOWNFROMINC: String(ensureDepartureCitySelected() || ""), STATEINC: String(countryNode.value || ""), TOURTYPE: String(tourTypeNode.value || ""), TOURINC: String(tourNode.value || ""), CHECKIN_BEG: toIsoDate(dateFromNode.value), CHECKIN_END: toIsoDate(dateToNode.value), TOUR_CHECKIN: toIsoDate(item.checkIn || item.dateFrom || context.dateFrom || ""), TOUR_CHECKOUT: toIsoDate(item.checkOut || item.dateTo || context.dateTo || ""), NIGHTS_FROM: String(nightsFromNode.value || ""), NIGHTS_TILL: String(nightsToNode.value || ""), ADULT: String((adultsNode && adultsNode.value) || "2"), CHILD: String(childsNode.value || "0"), CHILD1AGE: child1AgeNode.disabled ? "" : child1AgeNode.value, CHILD2AGE: child2AgeNode.disabled ? "" : child2AgeNode.value, CHILD_AGES: context.childAges || "", CURRENCY: String(item.currencyKey || "1"), FREIGHT: hasFlightSeatsNode && hasFlightSeatsNode.checked ? "1" : "" }; } function applyOptionsCalcToContext(context, optionsCalc, selected) { if (!optionsCalc || typeof optionsCalc !== "object") { return; } context.insuranceAvailable = !!(optionsCalc.insurance && optionsCalc.insurance.available); context.visaAvailable = !!(optionsCalc.visa && optionsCalc.visa.available); context.insuranceReason = optionsCalc.insurance && optionsCalc.insurance.reason ? String(optionsCalc.insurance.reason) : ""; context.visaReason = optionsCalc.visa && optionsCalc.visa.reason ? String(optionsCalc.visa.reason) : ""; if (optionsCalc.insurance && !optionsCalc.insurance.available && context.withInsurance) { context.withInsurance = false; } if (optionsCalc.visa && !optionsCalc.visa.available && context.withVisa) { context.withVisa = false; } if (selected) { var optTour = selected.priceTour !== undefined && selected.priceTour !== null && selected.priceTour !== "" ? selected.priceTour : selected.price; var optRub = selected.priceRub; if (isPositiveMoney(optTour) && !looksLikeFlightOnlyPrice(optTour, context.searchPriceTour || context.priceTour)) { context.priceTour = String(parseMoney(optTour)); } if (isPositiveMoney(optRub) && !looksLikeFlightOnlyPrice(optRub, context.searchPriceRub || context.priceRub)) { context.priceRub = String(Math.round(parseMoney(optRub))); } ensureFallbackTourPrices(context); if (selected.tourCurrency) { context.tourCurrency = selected.tourCurrency; } context.basePriceTour = selected.basePrice != null ? String(selected.basePrice) : (optionsCalc.basePrice != null ? String(optionsCalc.basePrice) : ""); context.insuranceAmount = selected.insuranceAmount != null ? String(selected.insuranceAmount) : (optionsCalc.insurance && optionsCalc.insurance.applied ? String(optionsCalc.insurance.amount) : "0"); context.visaAmount = selected.visaAmount != null ? String(selected.visaAmount) : (optionsCalc.visa && optionsCalc.visa.applied ? String(optionsCalc.visa.amount) : "0"); } context.optionsCalcNote = formatOptionsCalcStatus(optionsCalc, context); if (optionsCalc.warnings && optionsCalc.warnings.length) { context.optionsCalcWarnings = optionsCalc.warnings.join(" "); } else { context.optionsCalcWarnings = ""; } } function serviceOptionUnavailableText(kind, reason) { var isIns = kind === "insurance"; if (reason === "no_options") { return isIns ? "Для этого направления нет доступных вариантов страховки" : "Для этого направления нет доступных вариантов визы"; } if (reason === "load_failed") { return isIns ? "Не удалось рассчитать страховку автоматически — уточнит агент" : "Не удалось рассчитать визу автоматически — уточнит агент"; } if (reason === "no_price") { return isIns ? "Страховка найдена, но цена для расчета недоступна" : "Виза найдена, но цена для расчета недоступна"; } return isIns ? "Страховка недоступна для выбранного направления" : "Виза недоступна для выбранного направления"; } function collectOptionsCalcHints(context) { // Причины недоступности страховки/визы показываем только в зелёном блоке статуса ниже. return []; } function formatOptionsCalcStatus(optionsCalc, context) { if (!optionsCalc && !context) { return ""; } var parts = []; var currency = optionsCalc && optionsCalc.currency ? optionsCalc.currency : ""; if (optionsCalc && optionsCalc.insurance && optionsCalc.insurance.applied) { parts.push("страховка +" + formatMoney(optionsCalc.insurance.amount, currency)); } if (optionsCalc && optionsCalc.visa && optionsCalc.visa.applied) { parts.push("виза +" + formatMoney(optionsCalc.visa.amount, currency)); } if (context && context.insuranceAvailable === false) { parts.push(serviceOptionUnavailableText("insurance", context.insuranceReason)); } else if (optionsCalc && optionsCalc.insurance && optionsCalc.insurance.requested && !optionsCalc.insurance.available) { parts.push(serviceOptionUnavailableText("insurance", optionsCalc.insurance.reason)); } if (context && context.visaAvailable === false) { parts.push(serviceOptionUnavailableText("visa", context.visaReason)); } else if (optionsCalc && optionsCalc.visa && optionsCalc.visa.requested && !optionsCalc.visa.available) { parts.push(serviceOptionUnavailableText("visa", optionsCalc.visa.reason)); } return parts.length ? parts.join("; ") : ""; } function optionsCalcHintHtml() { return ''; } function renderOptionsCalcControls(context) { var insDisabled = context.insuranceAvailable === false; var visaDisabled = context.visaAvailable === false; var insHint = insDisabled ? serviceOptionUnavailableText("insurance", context.insuranceReason) : ""; var visaHint = visaDisabled ? serviceOptionUnavailableText("visa", context.visaReason) : ""; var insTitle = insHint ? ' title="' + escapeHtml(insHint) + '"' : ""; var visaTitle = visaHint ? ' title="' + escapeHtml(visaHint) + '"' : ""; return ( '
' + '" + '" + "
" + optionsCalcHintHtml() ); } function syncOptionsCalcUi(body, context) { var insCb = body.querySelector(".etw-options-calc__insurance"); var visaCb = body.querySelector(".etw-options-calc__visa"); var noteNode = body.querySelector(".etw-options-calc__note"); if (insCb) { insCb.checked = !!context.withInsurance; insCb.disabled = context.insuranceAvailable === false; var insLabel = insCb.closest(".etw-options-calc__item"); if (insLabel) { insLabel.classList.toggle("is-disabled", context.insuranceAvailable === false); insLabel.title = context.insuranceAvailable === false ? serviceOptionUnavailableText("insurance", context.insuranceReason) : ""; } } if (visaCb) { visaCb.checked = !!context.withVisa; visaCb.disabled = context.visaAvailable === false; var visaLabel = visaCb.closest(".etw-options-calc__item"); if (visaLabel) { visaLabel.classList.toggle("is-disabled", context.visaAvailable === false); visaLabel.title = context.visaAvailable === false ? serviceOptionUnavailableText("visa", context.visaReason) : ""; } } if (noteNode) { noteNode.hidden = true; noteNode.innerHTML = ""; } } function bindOptionsCalcControls(body, context, rerunActualize) { var insCb = body.querySelector(".etw-options-calc__insurance"); var visaCb = body.querySelector(".etw-options-calc__visa"); if (insCb) { insCb.addEventListener("change", function () { if (context.insuranceAvailable === false) { insCb.checked = false; return; } context.withInsurance = insCb.checked; context.fareNote = context.withInsurance ? "Пересчитываем цену со страховкой…" : "Пересчитываем цену без страховки…"; refreshModalContext(body, context); rerunActualize().catch(function () {}); }); } if (visaCb) { visaCb.addEventListener("change", function () { if (context.visaAvailable === false) { visaCb.checked = false; return; } context.withVisa = visaCb.checked; context.fareNote = context.withVisa ? "Пересчитываем цену с визой…" : "Пересчитываем цену без визы…"; refreshModalContext(body, context); rerunActualize().catch(function () {}); }); } } function normalizePlaceToken(value) { return String(value || "").toLowerCase().replace(/\s+/g, " ").trim(); } var DEPARTURE_CITY_IATA = { владивосток: "vvo", екатеринбург: "svx", ереван: "evn", москва: "mow", санктпетербург: "led", "санкт-Петербург": "led", новосибирск: "ovb", красноярск: "kja", иркутск: "ikt", хабаровск: "khv", казань: "kzn", сочи: "aer", минеральныеводы: "mrv", "минеральные воды": "mrv", самара: "kuf", уфа: "ufa", пермь: "pee", челябинск: "cek", омск: "oms", ростовнадону: "rov", "ростов-на-дону": "rov", калининград: "kgd", тюмень: "tjm", ярославль: "iar", нижнийновгород: "goj", "нижний новгород": "goj" }; function getDepartureMatchTokens(context) { var tokens = []; var name = normalizePlaceToken(context && context.departureCity ? context.departureCity : ""); if (name) { tokens.push(name); var compact = name.replace(/[-\s]/g, ""); if (DEPARTURE_CITY_IATA[name]) { tokens.push(DEPARTURE_CITY_IATA[name]); } else if (DEPARTURE_CITY_IATA[compact]) { tokens.push(DEPARTURE_CITY_IATA[compact]); } } if (depCityNode && depCityNode.value) { var town = (fullTownFroms || []).find(function (item) { return String(item.id) === String(depCityNode.value); }); if (town) { var townName = normalizePlaceToken(town.name || town.townName || ""); if (townName) { tokens.push(townName); var townCompact = townName.replace(/[-\s]/g, ""); if (DEPARTURE_CITY_IATA[townName]) { tokens.push(DEPARTURE_CITY_IATA[townName]); } else if (DEPARTURE_CITY_IATA[townCompact]) { tokens.push(DEPARTURE_CITY_IATA[townCompact]); } } if (town.iata || town.townIATA || town.alias) { tokens.push(normalizePlaceToken(town.iata || town.townIATA || town.alias)); } } } return tokens.filter(Boolean).filter(function (token, idx, arr) { return arr.indexOf(token) === idx; }); } function placeMatchesTokens(place, tokens) { var p = normalizePlaceToken(place); if (!p || !tokens || !tokens.length) { return false; } return tokens.some(function (token) { if (!token) { return false; } if (p === token) { return true; } // IATA-код внутри "Koltsovo (SVX)" и т.п. if (token.length === 3 && new RegExp("(?:^|[^a-z])" + token + "(?:[^a-z]|$)", "i").test(p)) { return true; } return token.length > 3 && (p.indexOf(token) >= 0 || token.indexOf(p) >= 0); }); } function orientFlightLegs(outbound, inbound, context) { var out = Array.isArray(outbound) ? outbound.slice() : []; var inn = Array.isArray(inbound) ? inbound.slice() : []; if (!out.length || !inn.length) { return { outbound: out, inbound: inn }; } var tokens = getDepartureMatchTokens(context); if (!tokens.length) { return { outbound: out, inbound: inn }; } var outStartsHome = placeMatchesTokens(out[0].from, tokens); var inStartsHome = placeMatchesTokens(inn[0].from, tokens); var outEndsHome = placeMatchesTokens(out[out.length - 1].to, tokens); var inEndsHome = placeMatchesTokens(inn[inn.length - 1].to, tokens); // GDS иногда отдаёт route_index наоборот: «туда» = прилёт домой. if ((!outStartsHome && inStartsHome) || (outEndsHome && !inEndsHome && !outStartsHome)) { return { outbound: inn, inbound: out }; } return { outbound: out, inbound: inn }; } function splitSegmentsByDirection(segments, context) { var list = Array.isArray(segments) ? segments : []; if (!list.length) { return { outbound: [], inbound: [] }; } var hasRouteIndex = list.some(function (seg) { return seg && (seg.routeIndex === 0 || seg.routeIndex === 1 || seg.routeIndex === "0" || seg.routeIndex === "1"); }); var outbound = []; var inbound = []; if (hasRouteIndex) { outbound = list.filter(function (seg) { return Number(seg.routeIndex) === 0; }); inbound = list.filter(function (seg) { return Number(seg.routeIndex) === 1; }); } else { var depTokens = getDepartureMatchTokens(context); var splitIdx = -1; if (depTokens.length) { for (var i = 1; i < list.length; i += 1) { if (placeMatchesTokens(list[i].from, depTokens) || placeMatchesTokens(list[i].to, depTokens)) { splitIdx = i; break; } } } if (splitIdx < 0 && list.length > 1) { splitIdx = Math.ceil(list.length / 2); } if (splitIdx < 0) { outbound = list; inbound = []; } else { outbound = list.slice(0, splitIdx); inbound = list.slice(splitIdx); } } return orientFlightLegs(outbound, inbound, context); } function sumSegmentsDurationMinutes(segments) { var list = Array.isArray(segments) ? segments : []; var total = 0; var matched = 0; list.forEach(function (seg) { var raw = String((seg && seg.duration) || "").trim(); if (!raw) { return; } var hm = raw.match(/^(\d+)\s*[:ч]\s*(\d+)/i); if (hm) { total += Number(hm[1]) * 60 + Number(hm[2]); matched += 1; return; } var onlyMin = toNumberSafe(raw); if (onlyMin !== null) { total += onlyMin; matched += 1; } }); return matched ? total : null; } function buildRouteFromLegs(outbound, inbound) { var out = Array.isArray(outbound) ? outbound : []; var inn = Array.isArray(inbound) ? inbound : []; var outFrom = out[0] && out[0].from ? String(out[0].from) : ""; var outTo = out.length ? String(out[out.length - 1].to || "") : ""; var inFrom = inn[0] && inn[0].from ? String(inn[0].from) : ""; var inTo = inn.length ? String(inn[inn.length - 1].to || "") : ""; if (outFrom && outTo && inFrom && inTo) { return outFrom + " - " + outTo + " / " + inFrom + " - " + inTo; } if (outFrom && outTo) { return outFrom + " - " + outTo; } return ""; } function collectLegBaggage(segments, fallback) { var bags = []; (Array.isArray(segments) ? segments : []).forEach(function (seg) { var bag = String((seg && seg.baggage) || "").trim(); if (bag && bags.indexOf(bag) < 0) { bags.push(bag); } }); if (bags.length) { return bags.join(", "); } return String(fallback || "").trim(); } function formatSegmentOneLine(seg) { var routeLine = [seg.from || "", seg.to || ""].filter(Boolean).join(" → "); var timeLine = [seg.departure || "", seg.arrival || ""].filter(Boolean).join(" – "); var carrier = [seg.carrier || "", seg.flightNumber || ""].filter(Boolean).join(" "); var bag = String(seg.baggage || "").trim(); return [routeLine || "Маршрут не указан", timeLine, seg.duration || "", carrier, bag ? "багаж: " + bag : ""] .filter(Boolean) .join(" · "); } function renderSegmentsDirection(title, segments, context, tone) { if (!Array.isArray(segments) || !segments.length) { return ""; } var durationText = formatMinutesRu(sumSegmentsDurationMinutes(segments)) || "уточняется"; var stopsText = normalizeStopsLabel(Math.max(segments.length - 1, 0), segments); var bagText = formatBaggageLabel(collectLegBaggage(segments, context && context.baggage)); return ( '
' + '
' + escapeHtml(title) + "
" + '
В пути: ' + escapeHtml(durationText) + " | " + escapeHtml(stopsText) + " | Багаж: " + escapeHtml(bagText) + "
" + '
' + segments.map(function (seg, idx) { return ( '
' + escapeHtml(formatSegmentOneLine(seg)) + "
" ); }).join("") + "
" + "
" ); } function renderSegmentsStructured(segments, context) { var split = splitSegmentsByDirection(segments, context); return ( '
' + renderSegmentsDirection("Туда", split.outbound, context, "out") + renderSegmentsDirection("Обратно", split.inbound, context, "back") + "
" ); } function getSelectedLabels(selectNode) { return Array.prototype.slice .call((selectNode && selectNode.options) || []) .filter(function (option) { return option.selected && option.value !== ""; }) .map(function (option) { return option.textContent || ""; }); } function buildRequestContext(item) { var selectedHotels = getSelectedLabels(hotelSelectNode); var selectedTowns = getSelectedLabels(tourCityNode); var selectedStars = getSelectedLabels(hotelStarsNode); var selectedMeals = getSelectedLabels(mealNode); var selectedTourType = tourTypeNode.options[tourTypeNode.selectedIndex] ? tourTypeNode.options[tourTypeNode.selectedIndex].text : ""; var selectedTour = tourNode.options[tourNode.selectedIndex] ? tourNode.options[tourNode.selectedIndex].text : ""; var priceMode = getPriceMode(); return { hotel: item.hotel || "", star: item.star || "", country: item.country || (countryNode.options[countryNode.selectedIndex] ? countryNode.options[countryNode.selectedIndex].text : ""), city: item.city || selectedTowns.join(", "), departureCity: item.departureCity || (depCityNode.options[depCityNode.selectedIndex] ? depCityNode.options[depCityNode.selectedIndex].text : ""), dateFrom: item.dateFrom || dateFromNode.value || "", dateTo: item.dateTo || dateToNode.value || "", nights: item.nights || "", adults: adultsNode ? adultsNode.value : "", childs: childsNode.value || "", child1Age: child1AgeNode.disabled ? "" : child1AgeNode.value, child2Age: child2AgeNode.disabled ? "" : child2AgeNode.value, childAges: [child1AgeNode.disabled ? "" : child1AgeNode.value, child2AgeNode.disabled ? "" : child2AgeNode.value].filter(Boolean).join(","), meal: item.meal || selectedMeals.join(", "), room: item.room || "", tourType: selectedTourType, tour: item.tour || selectedTour, hotelsFilter: selectedHotels.join(", "), townsFilter: selectedTowns.join(", "), starsFilter: selectedStars.join(", "), priceMode: priceMode, priceRub: item.priceRub || "", priceTour: item.priceTour || "", searchPriceRub: item.priceRub || "", searchPriceTour: item.priceTour || "", priceExact: false, tourCurrency: item.tourCurrency || "", priceFrom: priceFromNode ? priceFromNode.value : "", priceTo: priceToNode ? priceToNode.value : "", childInBed: childInBedNode && childInBedNode.checked ? "1" : "0", hasFlightSeats: hasFlightSeatsNode && hasFlightSeatsNode.checked ? "1" : "0", noStopSale: noStopSaleNode && noStopSaleNode.checked ? "1" : "0", momentConfirm: momentConfirmNode && momentConfirmNode.checked ? "1" : "0", agentEmail: agentEmail, host: statHost, widgetName: widgetName, widgetVersion: widgetVersion, resultId: item.resultId || "", catClaim: item.catClaim || "", hotelUrl: item.hotelUrl || "", tourLink: item.link || "", flightInCode: item.flightInCode || "", flightOutCode: item.flightOutCode || "", baggage: item.baggage || "", baggageRequired: "all", offerGuid: "", flightDuration: "", flightStops: "", flightSegmentsJson: "[]", flightSegmentsText: "", flightBasePrice: "", flightDelta: "0", flightAlternativesCount: "", baggageLabel: "", baggageRequiredLabel: "Не важно", flightAvailable: null, withInsurance: false, withVisa: false, insuranceAvailable: null, insuranceReason: "", visaReason: "", visaAvailable: null, basePriceTour: "", insuranceAmount: "0", visaAmount: "0", optionsCalcNote: "", optionsCalcWarnings: "", flightChangeAvailable: false, flightChangeNote: "Смена перелета будет доступна после уточнения рейса", route: item.route || "", fareNote: "Выполняется актуализация цены." }; } function renderPriceBreakdown(context) { var currency = context.tourCurrency ? " " + context.tourCurrency : ""; var parts = []; // Доплата за перелет показывается в блоке авиабилетов — здесь только опции к цене. if (context.basePriceTour && (Number(context.insuranceAmount) > 0 || Number(context.visaAmount) > 0)) { parts.push('
Базовая цена: ' + escapeHtml(context.basePriceTour + currency) + "
"); } if (Number(context.insuranceAmount) > 0) { parts.push('
Страховка: +' + escapeHtml(context.insuranceAmount + currency) + "
"); } if (Number(context.visaAmount) > 0) { parts.push('
Виза: +' + escapeHtml(context.visaAmount + currency) + "
"); } return parts.length ? '
' + parts.join("") + "
" : '
'; } function renderRequestMeta(context) { var childs = Number(context.childs || 0); var tourists = escapeHtml(String(context.adults || "0")) + " взр." + (childs > 0 ? ", " + escapeHtml(String(context.childs)) + " дет." + (context.childAges ? " (" + escapeHtml(context.childAges) + " лет)" : "") : ""); var placeBits = [context.country, context.city].filter(Boolean); var rows = []; if (placeBits.length) { rows.push('
Куда: ' + escapeHtml(placeBits.join(", ")) + "
"); } if (context.departureCity) { rows.push('
Откуда: ' + escapeHtml(context.departureCity) + "
"); } rows.push( '
Даты: ' + escapeHtml((context.dateFrom || "-") + " — " + (context.dateTo || "-")) + (context.nights ? " · " + escapeHtml(String(context.nights)) + " нч." : "") + "
" ); rows.push('
Туристы: ' + tourists + "
"); if (context.meal) { rows.push('
Питание: ' + escapeHtml(context.meal) + "
"); } if (context.room) { rows.push('
Номер: ' + escapeHtml(context.room) + "
"); } if (context.tour && context.tour !== context.hotel) { rows.push('
Программа: ' + escapeHtml(context.tour) + "
"); } return ( '
' + rows.join("") + renderPriceBreakdown(context) + "
" ); } function parseMoney(value) { if (value === undefined || value === null || value === "") { return null; } var n = Number(String(value).replace(/\s/g, "").replace(",", ".").replace(/[^\d.-]/g, "")); return Number.isFinite(n) ? n : null; } function isPositiveMoney(value) { var n = parseMoney(value); return n !== null && n > 0; } /** GDS часто отдаёт доплату за авиа (15–500), а не цену пакета. */ function looksLikeFlightOnlyPrice(candidate, baseline) { var c = parseMoney(candidate); var b = parseMoney(baseline); if (c === null || c <= 0) { return true; } if (b === null || b <= 0) { return false; } return c < b * 0.35; } function formatMoneyWithCurrency(amount, currency) { var n = parseMoney(amount); if (n === null) { return "-"; } var rounded = Math.round(n * 100) / 100; return String(rounded) + (currency ? " " + currency : ""); } function formatFlightDeltaLabel(delta, currency) { var n = parseMoney(delta); if (n === null) { n = 0; } var sign = n > 0 ? "+" : (n < 0 ? "" : "+"); return sign + formatMoneyWithCurrency(n, currency); } function packageBaseTourPrice(context) { return parseMoney(context.searchPriceTour) || parseMoney(context.priceTour) || 0; } /** * Цена пакета из поиска + доплата за выбранный GDS-перелет. * Сам GDS часто отдаёт только авиатариф — им нельзя затирать цену тура. */ function applyFlightDeltaToTourPrice(context, delta) { var base = packageBaseTourPrice(context); var d = parseMoney(delta); if (d === null) { d = 0; } context.flightDelta = String(d); if (base > 0) { context.priceTour = String(Math.round((base + d) * 100) / 100); context.priceExact = true; } var baseRub = parseMoney(context.searchPriceRub); if (baseRub !== null && baseRub > 0 && (!context.tourCurrency || /rub|руб/i.test(String(context.tourCurrency)))) { context.priceRub = String(Math.round(baseRub + d)); } } function ensureFallbackTourPrices(context) { if (!isPositiveMoney(context.priceTour) && isPositiveMoney(context.searchPriceTour)) { context.priceTour = String(parseMoney(context.searchPriceTour)); } if (!isPositiveMoney(context.priceRub) && isPositiveMoney(context.searchPriceRub)) { context.priceRub = String(Math.round(parseMoney(context.searchPriceRub))); } } function formatModalPriceText(context) { ensureFallbackTourPrices(context); var amount = ""; var currency = ""; if (context.priceMode === "rub") { amount = isPositiveMoney(context.priceRub) ? String(Math.round(parseMoney(context.priceRub))) : ""; currency = "RUB"; } else { amount = isPositiveMoney(context.priceTour) ? String(parseMoney(context.priceTour)) : ""; currency = context.tourCurrency || ""; } if (!amount) { return "Цена уточняется"; } return amount + (currency ? " " + currency : ""); } function syncModalPrice(context) { var modal = document.getElementById("etwTourModal"); if (!modal) { return; } var valueNode = modal.querySelector(".etw-tour-summary .etw-modal-price__value"); if (valueNode) { valueNode.textContent = formatModalPriceText(context); valueNode.classList.toggle("is-exact", !!context.priceExact); valueNode.classList.toggle("is-from", !context.priceExact); } } /** * Пишем цену тура только если сумма валидна и не похожа на тариф авиа. * exact=true — после успешной актуализации (точная), иначе «от». */ function applyTourDisplayPrice(context, tourPrice, rubPrice, exact) { var baseline = context.searchPriceTour || context.priceTour; if (isPositiveMoney(tourPrice) && !looksLikeFlightOnlyPrice(tourPrice, baseline)) { context.priceTour = String(parseMoney(tourPrice)); } if (isPositiveMoney(rubPrice) && !looksLikeFlightOnlyPrice(rubPrice, context.searchPriceRub || rubPrice)) { context.priceRub = String(Math.round(parseMoney(rubPrice))); } ensureFallbackTourPrices(context); var display = context.priceMode === "rub" ? context.priceRub : context.priceTour; context.priceExact = !!exact && isPositiveMoney(display); } function renderHiddenInputs(context) { return Object.keys(context) .map(function (key) { return ''; }) .join(""); } function renderEmpty() { tableContainer.innerHTML = '

К сожалению, мы не смогли найти ни одного тура по вашему запросу :(
Попробуйте изменить условия поиска.

'; } function renderResults(items, pager) { if (!items.length) { renderEmpty(); return; } pager = pager || lastSearchPager || { current: currentSearchPage || 1, total: 1 }; lastSearchPager = pager; currentSearchPage = pager.current || currentSearchPage || 1; renderResultMap = {}; var priceMode = getPriceMode(); var rows = items .map(function (item, idx) { var resultKey = "row_" + idx; renderResultMap[resultKey] = item; var departCell = formatFlightCell(item.dirDate || item.dateFrom, item.dirWeekDay, item.dirTimeFrom, item.dirTimeTo); var returnCell = formatFlightCell(item.bakDate || item.dateTo, item.bakWeekDay, item.bakTimeFrom, item.bakTimeTo); var priceLabel = "-"; if (priceMode === "rub") { priceLabel = item.priceRub ? String(item.priceRub) + " RUB" : "-"; } else if (item.priceTour) { // Price in operator/tour currency. var priceTourText = String(item.priceTour); if (item.tourCurrency && priceTourText.indexOf(item.tourCurrency) === -1) { priceTourText += " " + item.tourCurrency; } priceLabel = priceTourText; } var flightIn = decodeAvailabilityCode(item.flightInCode); var flightOut = decodeAvailabilityCode(item.flightOutCode); var hotel = decodeHotelAvailability(item.hotelAvailabilityCode); var marks = []; var flightMarks = '' + '«««' + '»»»' + ""; marks.push('рейс: ' + flightMarks + ""); marks.push('отель: ' + hotel.text + ""); if (item.momentConfirm) { marks.push('мгн. подтверждение'); } if (item.stopSale) { marks.push('stop-sale'); } var marksHtml = marks.join(" "); var tourCell = item.tour || "-"; if (item.programLink && tourCell !== "-") { tourCell = '' + tourCell + ""; } if (String(stripHtml(item.spoRaw)).trim() !== "") { var spoClass = hasHtmlTags(item.spoRaw) ? "etw-mark_spo_html" : "etw-mark_spo_plain"; tourCell += '
' + String(item.spoRaw) + "
"; } var hotelTitle = item.hotel || "-"; if (item.star) { hotelTitle += " " + item.star; } var hotelCell = hotelTitle; if (item.hotelUrl) { hotelCell = '' + hotelTitle + ""; } var priceButton = '"; return ( "" + "" + departCell + "" + "" + returnCell + "" + "" + (item.nights || "-") + "" + "" + (item.city || "-") + "" + "" + hotelCell + "" + "" + (item.room || "-") + "" + "" + (item.meal || "-") + "" + "" + tourCell + "" + "" + marksHtml + "" + "" + priceButton + "" + "" ); }) .join(""); tableContainer.innerHTML = '' + rows + "
ВылетВозвратНочейГород/курортГостиницаНомерПитаниеНазвание тураОтметкиЦена
" + renderSearchPager(pager); } async function fetchSearchResults(options) { options = options || {}; var params = buildSearchParamsFromForm(); if (options.pricePage) { params.PRICEPAGE = String(options.pricePage); currentSearchPage = Number(options.pricePage) || 1; } var payload = await fetchJSON(buildApiUrl("search", params)); var list = normalizePrices(payload); list = applyPriceRangeFilter( list, (priceFromNode && priceFromNode.value) || "", (priceToNode && priceToNode.value) || "", getPriceMode() ); var pager = extractSearchPager(payload); lastSearchPager = pager; currentSearchPage = pager.current || currentSearchPage || 1; return { items: list, pager: pager, raw: payload }; } function buildShareRestorePageOrder(startPage, totalPages) { var order = []; var seen = {}; var start = Number(startPage) || 1; var total = Number(totalPages) || 1; if (start >= 1 && start <= total) { order.push(start); seen[start] = true; } for (var page = 1; page <= total; page += 1) { if (!seen[page]) { order.push(page); } } return order; } async function findTourAcrossSearchPages(tourCriteria, searchParams) { var preferredPage = Number((tourCriteria && tourCriteria.pricePage) || (searchParams && searchParams.PRICEPAGE) || 1) || 1; var totalPages = 1; var totalSeen = 0; var pagesSearched = 0; var lastResult = null; var pageOrder = [preferredPage]; var page = 0; while (page < pageOrder.length && page < 100) { var pageNum = pageOrder[page]; if (pageNum < 1) { page += 1; continue; } var result = await fetchSearchResults({ pricePage: pageNum, skipStat: true }); lastResult = result; totalPages = Math.max(totalPages, result.pager.total || 1); pageOrder = buildShareRestorePageOrder(preferredPage, totalPages); pagesSearched += 1; totalSeen += result.items.length; var matched = findTourInSearchResults(result.items, tourCriteria); if (matched) { return { matched: matched, result: result, page: pageNum, totalSeen: totalSeen, pagesSearched: pagesSearched }; } page += 1; } return { matched: null, result: lastResult, page: preferredPage, totalSeen: totalSeen, pagesSearched: pagesSearched }; } async function loadSearchPage(page) { var nextPage = Number(page) || 1; if (nextPage < 1) { nextPage = 1; } currentSearchPage = nextPage; searchButton.disabled = true; setLoading(true, "40%"); try { await executeSearch(); setLoading(true, "100%"); } catch (error) { tableContainer.innerHTML = '

Ошибка загрузки данных: ' + error.message + "

"; } finally { setTimeout(function () { setLoading(false, "100%"); }, 300); searchButton.disabled = false; } } function ensureHotelModal() { var existing = document.getElementById("etwHotelModal"); if (existing) { return existing; } var modal = document.createElement("div"); modal.id = "etwHotelModal"; modal.className = "etw-hotel-modal"; modal.innerHTML = '
' + '
' + '
' + '
' + '' + "
" + '
Загрузка...
' + "
"; document.body.appendChild(modal); modal.querySelector(".etw-hotel-modal__backdrop").addEventListener("click", function () { modal.classList.remove("is-open"); }); modal.querySelector(".etw-hotel-modal__close").addEventListener("click", function () { modal.classList.remove("is-open"); }); return modal; } function renderHotelModalContent(content) { var images = Array.isArray(content.images) ? content.images : []; var tabs = content.tabs || {}; var tabKeys = ["tabs-place", "tabs-desc", "tabs-rooms", "tabs-service"]; var tabTitles = { "tabs-place": "Расположение", "tabs-desc": "Описание", "tabs-rooms": "Номера", "tabs-service": "Услуги" }; var gallery = ""; if (images.length) { var firstImage = images[0]; var thumbs = images .slice(0, 16) .map(function (src) { return ''; }) .join(""); gallery = '"; } var nav = tabKeys .filter(function (key) { return tabs[key]; }) .map(function (key, index) { return ( '" ); }) .join(""); var panels = tabKeys .filter(function (key) { return tabs[key]; }) .map(function (key, index) { return ( '
' + tabs[key] + "
" ); }) .join(""); return ( gallery + '
' + '
' + nav + "
" + '
' + panels + "
" + "
" ); } async function openHotelModal(hotelUrl, hotelTitle) { var modal = ensureHotelModal(); var bodyNode = modal.querySelector(".etw-hotel-modal__body"); var titleNode = modal.querySelector(".etw-hotel-modal__title"); titleNode.textContent = hotelTitle || "Карточка отеля"; modal.classList.add("is-open"); bodyNode.innerHTML = '
Загрузка...
'; try { var payload = await fetchJSON(buildApiUrl("hotelcontent", { url: hotelUrl })); var data = payload.data && payload.data.content ? payload.data.content : {}; bodyNode.innerHTML = renderHotelModalContent(data); var mainImg = bodyNode.querySelector(".etw-hotel-main img"); bodyNode.querySelectorAll(".etw-hotel-thumb").forEach(function (btn) { btn.addEventListener("click", function () { var src = btn.getAttribute("data-src"); if (mainImg && src) { mainImg.src = src; } }); }); bodyNode.querySelectorAll(".etw-hotel-tab").forEach(function (tabBtn) { tabBtn.addEventListener("click", function () { var tabKey = tabBtn.getAttribute("data-tab"); bodyNode.querySelectorAll(".etw-hotel-tab").forEach(function (btn) { btn.classList.toggle("is-active", btn === tabBtn); }); bodyNode.querySelectorAll(".etw-hotel-panel").forEach(function (panel) { panel.classList.toggle("is-active", panel.getAttribute("data-panel") === tabKey); }); }); }); } catch (error) { bodyNode.innerHTML = '
Не удалось загрузить карточку отеля.
'; } } function splitCsv(value) { return String(value || "") .split(",") .map(function (part) { return String(part || "").trim(); }) .filter(Boolean); } function getShareHashCode() { var hash = String(window.location.hash || "").replace(/^#/, "").trim(); if (!hash) { return ""; } // SAMO OnlineLink: 32 символа; legacy file-share: 10 символов. if ((hash.length === 32 || hash.length === 10) && /^[a-zA-Z0-9]+$/.test(hash)) { return hash; } return ""; } function clearShareHash() { if (!window.history || !window.history.replaceState) { return; } var path = (window.location.pathname || "") + (window.location.search || ""); window.history.replaceState(null, "", path); } function getSharePagePath() { return (window.location.pathname || "/") + (window.location.search || ""); } function buildSearchParamsFromForm() { var advancedOpen = isAdvancedFiltersOpen(); return { TOWNFROMINC: ensureDepartureCitySelected(), STATEINC: countryNode.value, TOURTYPEINC: tourTypeNode.value || "", TOURTYPE: tourTypeNode.value || "", TOURINC: tourNode.value, TOWNS: advancedOpen ? getSelectedValues(tourCityNode).join(",") : "", STARS: advancedOpen ? getSelectedValues(hotelStarsNode).join(",") : "", HOTELS: advancedOpen ? getSelectedValues(hotelSelectNode).join(",") : "", CHECKIN_BEG: toIsoDate(dateFromNode.value), CHECKIN_END: toIsoDate(dateToNode.value), NIGHTS_FROM: nightsFromNode.value, NIGHTS_TILL: nightsToNode.value, ADULT: (adultsNode && adultsNode.value) || "2", CHILD: childsNode.value, CHILD1AGE: child1AgeNode.disabled ? "" : child1AgeNode.value, CHILD2AGE: child2AgeNode.disabled ? "" : child2AgeNode.value, CHILD_AGES: (function () { var ages = []; if (!child1AgeNode.disabled) { ages.push(child1AgeNode.value); } if (!child2AgeNode.disabled) { ages.push(child2AgeNode.value); } return ages.join(","); })(), MEALS: advancedOpen ? getSelectedValues(mealNode).join(",") : "", COSTMIN: advancedOpen && priceFromNode ? priceFromNode.value || "" : "", COSTMAX: advancedOpen && priceToNode ? priceToNode.value || "" : "", CHILD_IN_BED: advancedOpen && childInBedNode && childInBedNode.checked ? 1 : "", COMFORTABLE_SEATS: advancedOpen && hasFlightSeatsNode && hasFlightSeatsNode.checked ? 1 : "", FILTER: advancedOpen && noStopSaleNode && noStopSaleNode.checked ? 1 : "", MOMENT_CONFIRM: advancedOpen && momentConfirmNode && momentConfirmNode.checked ? 1 : "", NCR: getPriceMode() === "rub" ? "true" : "false", PRICEPAGE: String(currentSearchPage || 1), limit: 100 }; } function extractSearchPager(payload) { var root = payload && payload.data ? payload.data : {}; var pager = root.result && root.result.pager ? root.result.pager : null; var current = pager && pager.current != null ? Number(pager.current) : currentSearchPage || 1; var total = pager && pager.total != null ? Number(pager.total) : 1; if (!Number.isFinite(current) || current < 1) { current = 1; } if (!Number.isFinite(total) || total < 1) { total = 1; } return { current: current, total: total }; } function renderSearchPager(pager) { pager = pager || { current: 1, total: 1 }; if (!pager.total || pager.total <= 1) { return ""; } var html = '
Страница:
'; var maxButtons = 12; var start = Math.max(1, pager.current - Math.floor(maxButtons / 2)); var end = Math.min(pager.total, start + maxButtons - 1); start = Math.max(1, end - maxButtons + 1); if (start > 1) { html += ''; } for (var page = start; page <= end; page += 1) { html += '"; } if (end < pager.total) { html += ''; } html += "
"; return html; } function buildSharePayloadData(context, item) { return { v: 1, search: buildSearchParamsFromForm(), tour: { resultId: item.resultId || context.resultId || "", catClaim: item.catClaim || context.catClaim || "", hotel: item.hotel || context.hotel || "", star: item.star || context.star || "", city: item.city || context.city || "", country: item.country || context.country || "", dateFrom: item.dateFrom || context.dateFrom || "", nights: item.nights || context.nights || "", meal: item.meal || context.meal || "", room: item.room || context.room || "", tour: item.tour || context.tour || "", flightInCode: context.flightInCode || item.flightInCode || "", flightOutCode: context.flightOutCode || item.flightOutCode || "", baggage: context.baggage || item.baggage || "", pricePage: currentSearchPage || 1 }, actualize: { baggageRequired: "all", baggageRequiredLabel: "Не важно", withInsurance: !!context.withInsurance, withVisa: !!context.withVisa, offerGuid: context.offerGuid || "" } }; } function parseSharePayload(raw) { var text = String(raw || "").trim(); if (!text) { return null; } if (text.charAt(0) === "{") { try { var parsed = JSON.parse(text); return parsed && typeof parsed === "object" ? parsed : null; } catch (e) { return null; } } try { var urlObj = new URL(text, window.location.href); var search = {}; urlObj.searchParams.forEach(function (value, key) { search[key] = value; }); return { v: 1, search: search, tour: {}, actualize: {} }; } catch (e2) { return null; } } async function fetchShareResolve(code) { var response = await fetch(buildApiUrl("share_resolve", { code: code })); var text = await response.text(); var payload = null; try { payload = JSON.parse(text); } catch (err) { throw new Error("API вернул не JSON"); } return { httpStatus: response.status, ok: !!(payload && payload.ok), data: payload && payload.data ? payload.data : null, message: (payload && payload.message) || "", error: (payload && payload.error) || (payload && payload.data && payload.data.error) || "" }; } async function createShareLinkRequest(payloadText) { var body = new URLSearchParams(); body.set("payload", payloadText); body.set("host", statHost); body.set("pagePath", getSharePagePath()); var response = await fetch(buildApiUrl("share", {}), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: body.toString() }); var text = await response.text(); var payload = null; try { payload = JSON.parse(text); } catch (err) { throw new Error("API вернул не JSON"); } if (!response.ok || !payload || payload.ok === false) { throw new Error((payload && payload.message) || "Не удалось создать ссылку."); } return payload.data || {}; } function setMultiSelectValues(selectNode, values) { var map = {}; (values || []).forEach(function (value) { map[String(value)] = true; }); Array.prototype.forEach.call(selectNode.options || [], function (option) { option.selected = !!map[option.value]; }); updateFormstoneSelect(selectNode, true); } function applySearchParamsToForm(search) { search = search || {}; if (search.TOWNFROMINC) { depCityNode.value = String(search.TOWNFROMINC); var town = fullTownFroms.find(function (item) { return String(item.id) === String(search.TOWNFROMINC); }); if (town && town.stateFromKey) { countryFromNode.value = String(town.stateFromKey); filterTownFromsByCountryFrom(); depCityNode.value = String(search.TOWNFROMINC); syncDepartureFieldPresentation(); } } if (search.STATEINC) { countryNode.value = String(search.STATEINC); } if (search.TOURTYPEINC || search.TOURTYPE) { tourTypeNode.value = String(search.TOURTYPEINC || search.TOURTYPE); } if (search.TOURINC) { tourNode.value = String(search.TOURINC); } if (search.CHECKIN_BEG) { dateFromNode.value = String(search.CHECKIN_BEG); } if (search.CHECKIN_END) { dateToNode.value = String(search.CHECKIN_END); } if (search.NIGHTS_FROM) { nightsFromNode.value = String(search.NIGHTS_FROM); } if (search.NIGHTS_TILL) { nightsToNode.value = String(search.NIGHTS_TILL); } if (search.ADULT && adultsNode) { adultsNode.value = String(search.ADULT); } if (search.CHILD !== undefined && search.CHILD !== "") { childsNode.value = String(search.CHILD); } updateChildAgeState(); if (search.CHILD1AGE !== undefined && search.CHILD1AGE !== "" && !child1AgeNode.disabled) { child1AgeNode.value = String(search.CHILD1AGE); } if (search.CHILD2AGE !== undefined && search.CHILD2AGE !== "" && !child2AgeNode.disabled) { child2AgeNode.value = String(search.CHILD2AGE); } if (priceFromNode && search.COSTMIN) { priceFromNode.value = String(search.COSTMIN); } if (priceToNode && search.COSTMAX) { priceToNode.value = String(search.COSTMAX); } if (childInBedNode) { childInBedNode.checked = String(search.CHILD_IN_BED) === "1"; } if (hasFlightSeatsNode) { hasFlightSeatsNode.checked = String(search.COMFORTABLE_SEATS || search.FREIGHT) === "1"; } if (noStopSaleNode) { noStopSaleNode.checked = String(search.FILTER) === "1"; } if (momentConfirmNode) { momentConfirmNode.checked = String(search.MOMENT_CONFIRM) === "1"; } var ncr = String(search.NCR || "").toLowerCase(); if (ncr === "true" || ncr === "1") { var rubNode = form.querySelector('input[name="ncr"][value="rub"]'); if (rubNode) { rubNode.checked = true; } } else if (ncr === "false" || ncr === "0") { var tourNodeRadio = form.querySelector('input[name="ncr"][value="tour"]'); if (tourNodeRadio) { tourNodeRadio.checked = true; } } pendingShareMultiSelect = { TOWNS: splitCsv(search.TOWNS), STARS: splitCsv(search.STARS), HOTELS: splitCsv(search.HOTELS), MEALS: splitCsv(search.MEALS) }; var hasAdvancedShare = pendingShareMultiSelect.TOWNS.length > 0 || pendingShareMultiSelect.STARS.length > 0 || pendingShareMultiSelect.HOTELS.length > 0 || pendingShareMultiSelect.MEALS.length > 0 || !!String(search.COSTMIN || "") || !!String(search.COSTMAX || "") || String(search.CHILD_IN_BED) === "1" || String(search.COMFORTABLE_SEATS || search.FREIGHT) === "1" || String(search.FILTER) === "1" || String(search.MOMENT_CONFIRM) === "1"; if (hasAdvancedShare) { setAdvancedFiltersVisible(true); } updateFormstoneSelect(countryFromNode, true); updateFormstoneSelect(depCityNode, true); updateFormstoneSelect(countryNode, true); updateFormstoneSelect(tourTypeNode, true); updateFormstoneSelect(tourNode, true); updateFormstoneSelect(nightsFromNode, true); updateFormstoneSelect(nightsToNode, true); updateFormstoneSelect(child1AgeNode, true); updateFormstoneSelect(child2AgeNode, true); refreshAllFormstoneSelects(true); } function applyPendingShareMultiSelects() { if (!pendingShareMultiSelect) { return []; } var hotels = pendingShareMultiSelect.HOTELS.slice(); if (pendingShareMultiSelect.TOWNS.length) { setMultiSelectValues(tourCityNode, pendingShareMultiSelect.TOWNS); } if (pendingShareMultiSelect.STARS.length) { setMultiSelectValues(hotelStarsNode, pendingShareMultiSelect.STARS); } if (pendingShareMultiSelect.MEALS.length) { setMultiSelectValues(mealNode, pendingShareMultiSelect.MEALS); } if (hotels.length) { rebuildHotelSelect(hotels); } pendingShareMultiSelect = null; return hotels; } function normalizeShareText(value) { return String(value || "") .trim() .toLowerCase() .replace(/\s+/g, " "); } function findTourInSearchResults(items, tour) { tour = tour || {}; if (!Array.isArray(items) || !items.length) { return null; } if (tour.resultId) { var byResultId = items.find(function (item) { return String(item.resultId) === String(tour.resultId); }); if (byResultId) { return byResultId; } } if (tour.catClaim) { var byClaim = items.find(function (item) { return String(item.catClaim) === String(tour.catClaim); }); if (byClaim) { return byClaim; } } var candidates = items.filter(function (item) { if (tour.hotel) { var hotelNeedle = normalizeShareText(tour.hotel); var hotelHay = normalizeShareText(item.hotel); if (hotelNeedle && hotelHay && hotelHay.indexOf(hotelNeedle) < 0 && hotelNeedle.indexOf(hotelHay) < 0) { return false; } } if (tour.dateFrom && toIsoDate(item.dateFrom) !== toIsoDate(tour.dateFrom)) { return false; } if (tour.nights && String(item.nights) !== String(tour.nights)) { return false; } if (tour.flightInCode && item.flightInCode && String(item.flightInCode) !== String(tour.flightInCode)) { return false; } if (tour.flightOutCode && item.flightOutCode && String(item.flightOutCode) !== String(tour.flightOutCode)) { return false; } if (tour.city && item.city && normalizeShareText(item.city) !== normalizeShareText(tour.city)) { return false; } return true; }); if (candidates.length === 1) { return candidates[0]; } if (candidates.length > 1 && tour.catClaim) { var byClaimLoose = candidates.find(function (item) { return String(item.catClaim) === String(tour.catClaim); }); if (byClaimLoose) { return byClaimLoose; } } return candidates.length ? candidates[0] : null; } function formatTourSearchCriteria(tour, resultsCount, pagesSearched) { tour = tour || {}; var rows = [ ["Отель", tour.hotel ? tour.hotel + (tour.star ? " " + tour.star : "") : "-"], ["Страна", tour.country || "-"], ["Курорт", tour.city || "-"], ["Дата заезда", tour.dateFrom || "-"], ["Ночей", tour.nights || "-"], ["Программа", tour.tour || "-"], ["Питание", tour.meal || "-"], ["Номер", tour.room || "-"], ["Перелёт туда", tour.flightInCode || "-"], ["Перелёт обратно", tour.flightOutCode || "-"], ["Багаж", tour.baggage || "-"] ]; var html = '

В результатах поиска (' + String(resultsCount || 0) + " туров на " + String(pagesSearched || 1) + " стр.) не удалось найти совпадение по критериям:

" + '"; return html; } function ensureInfoModal() { var existing = document.getElementById("etwInfoModal"); if (existing) { return existing; } var modal = document.createElement("div"); modal.id = "etwInfoModal"; modal.className = "etw-tour-modal etw-info-modal"; modal.innerHTML = '
' + '
' + '
' + '
' + '' + "
" + '
' + '' + "
"; document.body.appendChild(modal); modal.querySelector(".etw-tour-modal__backdrop").addEventListener("click", function () { modal.classList.remove("is-open"); }); modal.querySelector(".etw-tour-modal__close").addEventListener("click", function () { modal.classList.remove("is-open"); }); modal.querySelector(".etw-info-modal__ok").addEventListener("click", function () { modal.classList.remove("is-open"); }); return modal; } function showInfoModal(title, htmlBody, options) { options = options || {}; var modal = ensureInfoModal(); modal.querySelector(".etw-info-modal__title").textContent = title || "Сообщение"; modal.querySelector(".etw-info-modal__body").innerHTML = htmlBody || ""; modal.classList.toggle("etw-info-modal_no-footer", !!options.hideFooter); modal.classList.add("is-open"); return modal; } function showShareLinkModal(shareData) { shareData = shareData || {}; var shareUrl = shareData.shareUrl || ""; var expiresAt = shareData.expiresAt || ""; var expiresText = expiresAt ? new Date(expiresAt).toLocaleString("ru-RU") : "через 7 дней"; var html = '

Скопируйте ссылку и отправьте клиенту. По ней откроется этот тур с теми же параметрами поиска.

' + '
Ссылка хранится не более 7 дней и после этого перестанет работать. Даже в срок цена и наличие мест могут измениться — это ориентир, а не гарантия бронирования.
' + '' + '
' + '' + '' + "
" + '
Действует до: ' + escapeHtml(expiresText) + "
" + '
'; var modal = showInfoModal("Поделиться туром", html, { hideFooter: true }); var copyBtn = modal.querySelector(".etw-share-modal__copy"); var input = modal.querySelector(".etw-share-modal__input"); var status = modal.querySelector(".etw-share-modal__status"); if (copyBtn && input) { copyBtn.addEventListener("click", function () { input.focus(); input.select(); var done = function () { if (status) { status.textContent = "Ссылка скопирована."; } }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(shareUrl).then(done).catch(function () { try { document.execCommand("copy"); done(); } catch (e) { if (status) { status.textContent = "Не удалось скопировать автоматически."; } } }); } else { try { document.execCommand("copy"); done(); } catch (e2) { if (status) { status.textContent = "Скопируйте ссылку вручную."; } } } }); } } async function createAndShowShareLink(context, item) { try { var payloadData = buildSharePayloadData(context, item); var shareResult = await createShareLinkRequest(JSON.stringify(payloadData)); showShareLinkModal(shareResult); } catch (error) { showInfoModal("Не удалось создать ссылку", "

" + escapeHtml(error.message) + "

"); } } function scrollToTourLeadForm(modal) { modal = modal || document.getElementById("etwTourModal"); if (!modal) { return; } var scrollRoot = modal.querySelector(".etw-tour-modal__body"); var form = modal.querySelector(".etw-agent-form"); if (!scrollRoot || !form) { return; } var top = form.offsetTop; if (typeof scrollRoot.scrollTo === "function") { scrollRoot.scrollTo({ top: Math.max(0, top - 8), behavior: "smooth" }); } else { scrollRoot.scrollTop = Math.max(0, top - 8); } var nameInput = form.querySelector('input[name="name"]'); if (nameInput) { window.setTimeout(function () { try { nameInput.focus({ preventScroll: true }); } catch (e) { nameInput.focus(); } }, 280); } } function attachShareTourButton(body, context, item) { var modal = document.getElementById("etwTourModal"); if (!modal) { return; } var oldBodyShare = body && body.querySelector(".etw-share-tour"); if (oldBodyShare && oldBodyShare.parentNode) { oldBodyShare.parentNode.removeChild(oldBodyShare); } var link = modal.querySelector(".etw-tour-modal__share"); if (!link) { return; } link.onclick = function (event) { event.preventDefault(); createAndShowShareLink(context, item).catch(function () {}); }; } async function restoreFromSharePayload(payload) { if (!payload || !payload.search || !payload.search.TOWNFROMINC) { throw new Error("Некорректные данные ссылки."); } applySearchParamsToForm(payload.search); if (payload.search.PRICEPAGE) { currentSearchPage = Number(payload.search.PRICEPAGE) || 1; } else if (payload.tour && payload.tour.pricePage) { currentSearchPage = Number(payload.tour.pricePage) || 1; } else { currentSearchPage = 1; } await refreshControls(); var hotelsToRestore = applyPendingShareMultiSelects(); if (hotelsToRestore.length) { await refreshControls(); setMultiSelectValues(hotelSelectNode, hotelsToRestore); } if (progressTextNode) { progressTextNode.textContent = " - восстанавливаем тур по ссылке..."; } setLoading(true, "40%"); var lookup = await findTourAcrossSearchPages(payload.tour || {}, payload.search || {}); setLoading(true, "90%"); if (!lookup.matched) { if (lookup.result && lookup.result.items) { currentSearchPage = lookup.result.pager.current || 1; renderResults(lookup.result.items, lookup.result.pager); } showInfoModal( "Тур не найден", formatTourSearchCriteria(payload.tour || {}, lookup.totalSeen || 0, lookup.pagesSearched || 0) ); clearShareHash(); setLoading(false); return; } currentSearchPage = lookup.page || 1; renderResults(lookup.result.items, lookup.result.pager); clearShareHash(); setLoading(false); refreshAllFormstoneSelects(false); await openTourModal(lookup.matched, { shareRestore: payload.actualize || {} }); } async function tryRestoreFromShareHash() { var code = getShareHashCode(); if (!code) { return; } if (progressTextNode) { progressTextNode.textContent = " - загружаем тур по ссылке..."; } tableContainer.innerHTML = ""; setLoading(true, "15%"); try { var resolved = await fetchShareResolve(code); if (!resolved.ok) { var title = resolved.error === "expired" ? "Ссылка устарела" : "Ссылка недоступна"; var body = resolved.error === "expired" ? "

Срок хранения ссылки истёк (не более 7 дней). Попросите агента отправить новую ссылку на актуальный тур.

" : "

" + escapeHtml(resolved.message || "Короткая ссылка не найдена или повреждена.") + "

"; showInfoModal(title, body); clearShareHash(); return; } var payload = parseSharePayload(resolved.data && resolved.data.payload); if (!payload || !payload.search || !payload.search.TOWNFROMINC) { showInfoModal("Ссылка недоступна", "

Не удалось прочитать сохранённые параметры тура.

"); clearShareHash(); return; } await restoreFromSharePayload(payload); } catch (error) { showInfoModal("Ошибка восстановления", "

" + escapeHtml(error.message) + "

"); clearShareHash(); } finally { setLoading(false); } } function bindTourModalHeaderActions(modal) { if (!modal || modal.getAttribute("data-header-bound") === "1") { return; } modal.setAttribute("data-header-bound", "1"); modal.addEventListener("click", function (event) { var target = event.target; if (target && target.classList && target.classList.contains("etw-modal-order")) { scrollToTourLeadForm(modal); } }); } function ensureTourModalHeaderLayout(modal) { if (!modal) { return; } var header = modal.querySelector(".etw-tour-modal__header"); var closeBtn = modal.querySelector(".etw-tour-modal__close"); if (!header || !closeBtn) { return; } var right = modal.querySelector(".etw-tour-modal__header-right"); if (!right) { right = document.createElement("div"); right.className = "etw-tour-modal__header-right"; header.appendChild(right); } if (!right.contains(closeBtn)) { right.appendChild(closeBtn); } // Цена/Заказать переехали в тело формы — убираем из шапки. Array.prototype.slice.call(right.querySelectorAll(".etw-modal-price-block, .etw-modal-price, .etw-modal-order")).forEach(function (node) { if (node && node.parentNode) { node.parentNode.removeChild(node); } }); var shareLink = right.querySelector(".etw-tour-modal__share"); if (!shareLink) { shareLink = document.createElement("a"); shareLink.href = "#"; shareLink.className = "etw-tour-modal__share"; shareLink.textContent = "Поделиться туром"; right.insertBefore(shareLink, closeBtn); } else if (shareLink.nextSibling !== closeBtn) { right.insertBefore(shareLink, closeBtn); } bindTourModalHeaderActions(modal); } function ensureTourModal() { var existing = document.getElementById("etwTourModal"); if (existing) { ensureTourModalHeaderLayout(existing); return existing; } var modal = document.createElement("div"); modal.id = "etwTourModal"; modal.className = "etw-tour-modal"; modal.innerHTML = '
' + '
' + '
' + '
Тур
' + '" + "
" + '
' + "
"; document.body.appendChild(modal); bindTourModalHeaderActions(modal); modal.querySelector(".etw-tour-modal__backdrop").addEventListener("click", function () { modal.classList.remove("is-open"); }); modal.querySelector(".etw-tour-modal__close").addEventListener("click", function () { modal.classList.remove("is-open"); }); return modal; } function ensureFlightModal() { var existing = document.getElementById("etwFlightModal"); if (existing) { return existing; } var modal = document.createElement("div"); modal.id = "etwFlightModal"; modal.className = "etw-tour-modal"; modal.innerHTML = '
' + '
' + '
' + '
Выбор перелета
' + '' + "
" + '
' + "
"; document.body.appendChild(modal); modal.querySelector(".etw-tour-modal__backdrop").addEventListener("click", function () { modal.classList.remove("is-open"); }); modal.querySelector(".etw-tour-modal__close").addEventListener("click", function () { modal.classList.remove("is-open"); }); return modal; } function buildTourHotelPlace(item, context) { context = context || {}; item = item || {}; var hotelName = item.hotel || "-"; if (item.star) { hotelName += " " + item.star; } var placeLine = [context.city || item.city, context.country || item.country].filter(Boolean).join(", "); return { hotelName: hotelName, placeLine: placeLine }; } function setTourModalTitle(modal, item, context) { if (!modal) { return; } var titleNode = modal.querySelector(".etw-tour-modal__title"); if (!titleNode) { return; } var info = buildTourHotelPlace(item, context); titleNode.innerHTML = '
' + escapeHtml(info.hotelName) + "
" + (info.placeLine ? '
' + escapeHtml(info.placeLine) + "
" : ""); } function renderTourSummary(item, context) { var priceText = formatModalPriceText(context); var priceClass = "etw-modal-price__value" + (context.priceExact ? " is-exact" : " is-from"); return ( '
' + '
' + '
' + '' + escapeHtml(priceText) + "" + "
" + '' + "
" + renderRequestMeta(context) + '
' + renderFlightSummaryBlock(context) + "
" + '
' + renderOptionsCalcControls(context) + '
' + '
' + escapeHtml(context.fareNote || "Уточняем цену и перелет…") + "
" + '
Проверяем актуальность предложения…
' + "
" + "
" ); } function syncFareWarningUi(body, context, state) { var box = body && body.querySelector(".etw-fare-warning"); var note = body && body.querySelector(".etw-fare-warning__note"); var status = body && body.querySelector(".etw-actualize-status"); if (!box) { return; } var mode = state || "loading"; box.className = "etw-fare-warning etw-fare-warning_" + mode; if (note) { var noteText = context.fareNote || ""; if (mode === "ok" && (!noteText || /актуализа/i.test(noteText))) { noteText = "Цена и перелет подтверждены."; } if (mode === "loading" && !noteText) { noteText = "Уточняем цену и перелет…"; } note.textContent = noteText; note.style.display = noteText ? "" : "none"; } if (status && mode === "ok") { // Без дубля маршрута — он уже в блоке авиабилетов. var statusText = context.optionsCalcNote ? String(context.optionsCalcNote) : ""; status.textContent = statusText; if (!statusText.trim()) { status.style.display = "none"; } else { status.style.display = ""; } } else if (status) { status.style.display = ""; } } function refreshModalContext(body, context) { syncModalPrice(context); var metaGrid = body.querySelector(".etw-tour-summary__meta-grid"); if (metaGrid) { var tmp = document.createElement("div"); tmp.innerHTML = renderRequestMeta(context); if (tmp.firstChild) { metaGrid.parentNode.replaceChild(tmp.firstChild, metaGrid); } } var fareNoteNode = body.querySelector(".etw-fare-warning__note"); if (fareNoteNode && context.fareNote) { fareNoteNode.textContent = context.fareNote; fareNoteNode.style.display = ""; } syncFlightSummaryUi(body, context); var hiddenPairs = { priceRub: context.priceRub || "", priceTour: context.priceTour || "", priceExact: context.priceExact ? "1" : "0", tourCurrency: context.tourCurrency || "", flightInCode: context.flightInCode || "", flightOutCode: context.flightOutCode || "", baggage: context.baggage || "", route: context.route || "", fareNote: context.fareNote || "", withInsurance: context.withInsurance ? "1" : "0", withVisa: context.withVisa ? "1" : "0", basePriceTour: context.basePriceTour || "", insuranceAmount: context.insuranceAmount || "0", visaAmount: context.visaAmount || "0", baggageRequired: "all", baggageRequiredLabel: "Не важно", offerGuid: context.offerGuid || "", flightDuration: context.flightDuration || "", flightStops: context.flightStops || "", flightDelta: context.flightDelta || "0", flightSegmentsText: context.flightSegmentsText || "", baggageLabel: context.baggageLabel || "" }; Object.keys(hiddenPairs).forEach(function (name) { var input = body.querySelector('input[name="' + name + '"]'); if (input) { input.value = hiddenPairs[name]; } }); } function renderFlightAlternatives(body, alternatives, context) { updateFlightChangeAvailability(context, alternatives, { ok: true }); syncFlightSummaryUi(body, context); var holder = body.querySelector(".etw-flight-alts"); if (holder) { holder.innerHTML = ""; } } function hideFlightAlternatives(body, context, meta) { if (context) { updateFlightChangeAvailability(context, [], meta || { failed: true }); syncFlightSummaryUi(body, context); } var holder = body && body.querySelector(".etw-flight-alts"); if (holder) { holder.innerHTML = ""; } } function startActualizeProgress(statusNode) { if (!statusNode) { return function () {}; } var box = statusNode.closest(".etw-fare-warning"); if (box) { box.className = "etw-fare-warning etw-fare-warning_loading"; } var steps = [ "Проверяем актуальность предложения", "Проверяем актуальность предложения.", "Проверяем актуальность предложения..", "Проверяем актуальность предложения..." ]; var idx = 0; statusNode.className = "etw-actualize-status etw-actualize-status_loading"; statusNode.style.display = ""; statusNode.textContent = steps[idx]; var timer = setInterval(function () { idx = (idx + 1) % steps.length; statusNode.textContent = steps[idx]; }, 450); return function () { clearInterval(timer); }; } function countOfferStops(segments) { var list = Array.isArray(segments) ? segments : []; if (!list.length) { return 0; } var byRoute = {}; var hasRouteIndex = false; list.forEach(function (seg) { if (seg && (seg.routeIndex === 0 || seg.routeIndex === 1 || seg.routeIndex === "0" || seg.routeIndex === "1")) { hasRouteIndex = true; var key = String(Number(seg.routeIndex)); byRoute[key] = (byRoute[key] || 0) + 1; } }); if (hasRouteIndex) { return Object.keys(byRoute).reduce(function (sum, key) { return sum + Math.max((byRoute[key] || 0) - 1, 0); }, 0); } // Без routeIndex: туда/обратно примерно пополам — пересадки внутри каждой половины. if (list.length <= 2) { return 0; } var mid = Math.ceil(list.length / 2); return Math.max(mid - 1, 0) + Math.max(list.length - mid - 1, 0); } function ensureFlightPickerStyles() { var style = document.getElementById("etwFlightPickerStyles"); if (!style) { style = document.createElement("style"); style.id = "etwFlightPickerStyles"; document.head.appendChild(style); } style.textContent = "#etwFlightModal .etw-flight-alt-top{display:flex!important;flex-direction:row!important;align-items:flex-start!important;justify-content:space-between!important;gap:16px;width:100%}" + "#etwFlightModal .etw-flight-alt-main{flex:1 1 auto;min-width:0}" + "#etwFlightModal .etw-flight-alt-price{flex:0 0 auto!important;margin-left:auto!important;min-width:148px;max-width:46%;text-align:right!important;padding:10px 12px;border-radius:8px;background:#fff0e4!important;border:1px solid #e8c4a0!important}" + "#etwFlightModal .etw-flight-alt-price__label{font-size:12px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:#a04000!important}" + "#etwFlightModal .etw-flight-alt-price__value{margin-top:4px;font-size:22px!important;font-weight:800!important;line-height:1.15;color:#c0392b!important;white-space:nowrap}" + "#etwFlightModal .etw-flight-alt-price__total{margin-top:4px;font-size:12px;font-weight:700;color:#3d4f63;white-space:nowrap}" + "@media (max-width:640px){#etwFlightModal .etw-flight-alt-top{flex-direction:column!important}#etwFlightModal .etw-flight-alt-price{width:100%;max-width:none;margin-left:0!important;text-align:left!important}}"; } function openFlightAlternativesModal(alternatives, context, parentBody, rerunActualize) { ensureFlightPickerStyles(); var modal = ensureFlightModal(); var body = modal.querySelector(".etw-tour-modal__body"); var alts = Array.isArray(alternatives) ? alternatives : []; var withStopsCount = alts.filter(function (alt) { return countOfferStops(alt && alt.segments) > 0; }).length; var renderRows = function (filterMode) { var filtered = alts.filter(function (alt) { var stops = countOfferStops(alt && alt.segments); if (filterMode === "direct") { return stops <= 0; } if (filterMode === "stops") { return stops > 0; } if (filterMode === "with") { return hasBaggageOption(alt.baggage) === true; } if (filterMode === "without") { return hasBaggageOption(alt.baggage) === false; } return true; }); if (!filtered.length) { if (filterMode === "stops") { return '
В этой выдаче GDS нет рейсов с пересадками — только прямые.
'; } return '
Нет вариантов по выбранному фильтру.
'; } return filtered.map(function (alt) { var currency = alt.tourCurrency || context.tourCurrency || ""; var delta = alt.deltaPrice != null && alt.deltaPrice !== "" ? Number(alt.deltaPrice) : 0; if (!Number.isFinite(delta)) { delta = 0; } var deltaText = formatFlightDeltaLabel(delta, currency); var packageBase = packageBaseTourPrice(context); var totalWithFlight = packageBase > 0 ? formatMoneyWithCurrency(packageBase + delta, currency || context.tourCurrency || "") : ""; var split = splitSegmentsByDirection(alt.segments, context); var routeLabel = buildRouteFromLegs(split.outbound, split.inbound) || buildRouteLabel(alt.route, context, alt); var outDur = formatMinutesRu(sumSegmentsDurationMinutes(split.outbound)); var inDur = formatMinutesRu(sumSegmentsDurationMinutes(split.inbound)); var durationText = [outDur ? "туда " + outDur : "", inDur ? "обратно " + inDur : ""].filter(Boolean).join(", ") || alt.durationText || formatMinutesRu(alt.durationMinutes); var stopCount = countOfferStops(alt.segments); var stopsText = stopCount > 0 ? (stopCount + " пересад.") : "без пересадок"; var outboundCount = split.outbound.length; var inboundCount = split.inbound.length; var encodedSegments = encodeURIComponent(JSON.stringify(Array.isArray(alt.segments) ? alt.segments : [])); var detailsLabel = stopCount > 0 ? ("Показать пересадки (" + stopCount + ")") : "Показать детали рейса"; var flightSignature = function (offer) { var segs = Array.isArray(offer && offer.segments) ? offer.segments : []; return segs.map(function (seg) { return [ seg.from || "", seg.to || "", seg.flightNumber || "", seg.departure || "", seg.arrival || "", formatBaggageLabel(seg.baggage || (offer && offer.baggage) || "") ].join("~"); }).join("|"); }; var thisSig = flightSignature(alt); var sameItineraryCount = thisSig ? filtered.filter(function (other) { return flightSignature(other) === thisSig; }).length : 1; var priceWhy = sameItineraryCount > 1 ? "Тот же рейс — другой тариф авиакомпании (поэтому доплата другая)" : ""; return '
' + '
' + '
' + '
Вариант ' + escapeHtml(String(alt.index || "")) + ": " + escapeHtml(routeLabel) + "
" + '
Багаж: ' + escapeHtml(formatBaggageLabel(alt.baggage)) + "
" + '
В пути: ' + escapeHtml(durationText || "уточняется") + " | " + escapeHtml(stopsText) + " (туда: " + escapeHtml(String(outboundCount || 0)) + ", обратно: " + escapeHtml(String(inboundCount || 0)) + ")
" + (priceWhy ? '
' + escapeHtml(priceWhy) + "
" : "") + "
" + '
' + '
Доплата к туру
' + '
' + escapeHtml(deltaText) + "
" + (totalWithFlight ? '
Итого тур: ' + escapeHtml(totalWithFlight) + "
" : "") + "
" + "
" + '
' + renderSegmentsStructured(alt.segments, context) + "
" + '
' + '" + '' + "
" + "
"; }).join(""); }; body.innerHTML = '
' + '
Вариантов: ' + escapeHtml(String(alts.length)) + ", с пересадками: " + escapeHtml(String(withStopsCount)) + (withStopsCount === 0 ? ". GDS вернул только прямые рейсы." : ".") + "
" + '
' + '' + '' + '' + '' + '' + "
" + '
' + renderRows("all") + "
" + "
"; modal.classList.add("is-open"); var bindCardEvents = function () { body.querySelectorAll(".etw-flight-alt-toggle").forEach(function (toggle) { toggle.addEventListener("click", function () { var card = toggle.closest(".etw-flight-alt-btn"); if (!card) { return; } var expanded = card.classList.toggle("is-expanded"); var hasStops = card.classList.contains("has-stops"); if (expanded) { toggle.textContent = hasStops ? "Свернуть пересадки" : "Свернуть детали"; } else { toggle.textContent = hasStops ? toggle.getAttribute("data-open-label") || "Показать пересадки" : "Показать детали рейса"; } }); var card = toggle.closest(".etw-flight-alt-btn"); if (card && card.classList.contains("has-stops")) { toggle.setAttribute("data-open-label", toggle.textContent); } }); body.querySelectorAll(".etw-flight-alt-select").forEach(function (btn) { btn.addEventListener("click", function () { context.offerGuid = btn.getAttribute("data-offer-guid") || context.offerGuid; context.tourCurrency = btn.getAttribute("data-currency") || context.tourCurrency; context.flightInCode = btn.getAttribute("data-in") || context.flightInCode; context.flightOutCode = btn.getAttribute("data-out") || context.flightOutCode; context.baggage = btn.getAttribute("data-baggage") || context.baggage; context.baggageLabel = formatBaggageLabel(context.baggage); context.route = btn.getAttribute("data-route") || context.route; context.flightDuration = btn.getAttribute("data-duration") || context.flightDuration; context.flightStops = btn.getAttribute("data-stops") || context.flightStops; applyFlightDeltaToTourPrice(context, btn.getAttribute("data-delta") || "0"); context.fareNote = "Выбран альтернативный перелет. Пересчитываем цену…"; var segments = parseSegmentsSafe(btn.getAttribute("data-segments")); context.flightSegmentsJson = JSON.stringify(segments); context.flightSegmentsText = formatSegmentsText(segments); refreshModalContext(parentBody, context); syncModalPrice(context); modal.classList.remove("is-open"); if (typeof rerunActualize === "function") { rerunActualize().catch(function () {}); return; } var statusNode = parentBody.querySelector(".etw-actualize-status"); if (statusNode) { statusNode.className = "etw-actualize-status etw-actualize-status_ok"; statusNode.innerHTML = "Выбран альтернативный перелет: " + escapeHtml(context.route) + ". " + "Багаж: " + escapeHtml(formatBaggageLabel(context.baggage)) + ". " + "В пути: " + escapeHtml(context.flightDuration || "уточняется") + ", " + escapeHtml(context.flightStops || "пересадки: уточняется") + "." + renderSegmentsStructured(segments, context); } }); }); }; body.querySelectorAll(".etw-flight-baggage-filter__btn").forEach(function (filterBtn) { filterBtn.addEventListener("click", function () { body.querySelectorAll(".etw-flight-baggage-filter__btn").forEach(function (btn) { btn.classList.toggle("is-active", btn === filterBtn); }); var mode = filterBtn.getAttribute("data-bag-filter") || "all"; var listNode = body.querySelector(".etw-flight-alts__list_modal"); if (listNode) { listNode.innerHTML = renderRows(mode); bindCardEvents(); } }); }); bindCardEvents(); } function renderHotelInfoBlock(content) { var images = Array.isArray(content.images) ? content.images : []; var tabs = content.tabs || {}; var desc = tabs["tabs-desc"] || tabs["tabs-place"] || ""; var service = tabs["tabs-service"] || ""; var gallery = ""; if (images.length) { var first = images[0]; var thumbs = images .slice(0, 8) .map(function (src) { return ''; }) .join(""); gallery = '"; } return ( '
' + gallery + (desc ? '
' + desc + "
" : "") + (service ? '
' + service + "
" : "") + "
" ); } function renderAgentForm(context) { return ( '
' + '
Оставить заявку
' + renderHiddenInputs(context) + '
' + '
' + '' + '' + '
' + '
Мессенджер
' + '' + '' + '' + '' + "
" + "
" + '
' + '' + '' + "
" + "
" + '
' + '' + "
" ); } async function submitLeadForm(formNode) { var submitBtn = formNode.querySelector(".etw-agent-submit"); var statusNode = formNode.querySelector(".etw-agent-form__status"); var formData = new FormData(formNode); var params = new URLSearchParams(); formData.forEach(function (value, key) { params.append(key, value); }); if (statusNode) { statusNode.textContent = "Отправляем заявку..."; statusNode.className = "etw-agent-form__status is-loading"; } if (submitBtn) { submitBtn.disabled = true; } try { var response = await fetch(buildApiUrl("lead", {}), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: params.toString() }); var payload = await response.json(); if (!response.ok || !payload || payload.ok === false) { throw new Error((payload && payload.message) || "Не удалось отправить заявку."); } if (statusNode) { statusNode.textContent = "Заявка отправлена. Агент скоро свяжется с вами."; statusNode.className = "etw-agent-form__status is-success"; } } catch (error) { if (statusNode) { statusNode.textContent = "Ошибка отправки: " + error.message; statusNode.className = "etw-agent-form__status is-error"; } } finally { if (submitBtn) { submitBtn.disabled = false; } } } async function openTourModal(item, modalOptions) { modalOptions = modalOptions || {}; var shareRestore = modalOptions.shareRestore || null; var pendingShareOfferGuid = ""; var modal = ensureTourModal(); var body = modal.querySelector(".etw-tour-modal__body"); var context = buildRequestContext(item); if (shareRestore) { if (shareRestore.withInsurance) { context.withInsurance = true; } if (shareRestore.withVisa) { context.withVisa = true; } if (shareRestore.offerGuid) { pendingShareOfferGuid = String(shareRestore.offerGuid); } } setTourModalTitle(modal, item, context); modal.classList.add("is-open"); syncModalPrice(context); body.innerHTML = renderTourSummary(item, context) + '
Описание отеля
Загрузка...
' + renderAgentForm(context); if (jq && jq.fn && jq.fn.checkbox) { jq(body).find(".sf-field__radio").checkbox(); } var leadFormNode = body.querySelector(".etw-agent-form"); if (leadFormNode) { leadFormNode.addEventListener("submit", function (event) { event.preventDefault(); submitLeadForm(leadFormNode); }); } attachShareTourButton(body, context, item); var runActualize = async function () { var statusNodeOk = body.querySelector(".etw-actualize-status"); var stopProgress = startActualizeProgress(statusNodeOk); hideFlightAlternatives(body, context, { loading: true }); try { var actualizeOptions = {}; if (pendingShareOfferGuid) { actualizeOptions.skipOfferGuid = true; actualizeOptions.offerGuidHint = pendingShareOfferGuid; pendingShareOfferGuid = ""; } var actualized = await fetchJSON(buildApiUrl("actualize", buildActualizeApiParams(context, item, actualizeOptions))); stopProgress(); statusNodeOk = body.querySelector(".etw-actualize-status"); var data = actualized && actualized.data ? actualized.data : null; if (data && data.ok && data.matched) { var matched = data.matched; // Цена из выдачи SAMO (пакет), не тариф GDS. applyTourDisplayPrice( context, matched.price, matched.convertedPriceNumber || matched.convertedPrice, false ); if (matched.currencyAlias || matched.currency) { context.tourCurrency = matched.currencyAlias || matched.currency || context.tourCurrency; } context.flightInCode = matched.freights && matched.freights.econom ? String(matched.freights.econom.in || context.flightInCode) : context.flightInCode; context.flightOutCode = matched.freights && matched.freights.econom ? String(matched.freights.econom.out || context.flightOutCode) : context.flightOutCode; context.baggage = matched.freights && matched.freights.econom ? String(matched.freights.econom.baggage || context.baggage) : context.baggage; context.route = buildRouteLabel("", context, { flightInCode: context.flightInCode, flightOutCode: context.flightOutCode }); context.fareNote = data.message || "Цена от. Выполнена актуализация."; if (data.fullActualize && data.fullActualize.ok && data.fullActualize.selected) { var selected = data.fullActualize.selected; applyFlightCalcToContext(context, data.fullActualize.flightCalc || null, selected); var gdsTourPrice = selected.priceTour !== undefined && selected.priceTour !== null && selected.priceTour !== "" ? selected.priceTour : selected.price; var gdsRubPrice = selected.priceRub; var selectedDelta = selected.deltaPrice != null ? selected.deltaPrice : (data.fullActualize.flightCalc && data.fullActualize.flightCalc.deltaPrice != null ? data.fullActualize.flightCalc.deltaPrice : context.flightDelta); // Не затираем цену тура «аэротарифом» 0 / 15 / 299 — берём пакет + доплата за рейс. if (isPositiveMoney(gdsTourPrice) && !looksLikeFlightOnlyPrice(gdsTourPrice, context.searchPriceTour || context.priceTour)) { applyTourDisplayPrice(context, gdsTourPrice, gdsRubPrice, true); if (selectedDelta != null && selectedDelta !== "") { context.flightDelta = String(parseMoney(selectedDelta) || 0); } } else { applyFlightDeltaToTourPrice(context, selectedDelta); } } else if (data.fullActualize && data.fullActualize.flightCalc) { applyFlightCalcToContext(context, data.fullActualize.flightCalc, null); applyFlightDeltaToTourPrice(context, data.fullActualize.flightCalc.deltaPrice); context.priceExact = false; ensureFallbackTourPrices(context); } else { context.priceExact = false; ensureFallbackTourPrices(context); } if (data.fullActualize && data.fullActualize.optionsCalc) { applyOptionsCalcToContext(context, data.fullActualize.optionsCalc, data.fullActualize.selected || null); if (context.optionsCalcWarnings) { context.fareNote = context.optionsCalcWarnings; } else if (context.optionsCalcNote) { context.fareNote = "Цена пересчитана: " + context.optionsCalcNote + "."; } } else { context.basePriceTour = ""; context.insuranceAmount = "0"; context.visaAmount = "0"; context.optionsCalcNote = ""; context.optionsCalcWarnings = ""; } syncOptionsCalcUi(body, context); refreshModalContext(body, context); attachShareTourButton(body, context, item); if (data.fullActualize && data.fullActualize.ok) { var alts = Array.isArray(data.fullActualize.alternatives) ? data.fullActualize.alternatives : []; renderFlightAlternatives(body, alts, context); } else { hideFlightAlternatives(body, context, { failed: true, message: data.fullActualize && data.fullActualize.message ? data.fullActualize.message : "" }); } if (statusNodeOk) { if (data.fullActualize && data.fullActualize.ok) { statusNodeOk.className = "etw-actualize-status etw-actualize-status_ok"; context.fareNote = "Цена и перелет подтверждены."; if (data.fullActualize.alternatives && data.fullActualize.alternatives.length > 1) { context.fareNote = "Цена подтверждена. Можно сменить перелет."; } syncFareWarningUi(body, context, "ok"); } else { statusNodeOk.className = "etw-actualize-status etw-actualize-status_warn"; var failMsg = (data.fullActualize && data.fullActualize.message) ? String(data.fullActualize.message) : ""; var warnText = failMsg ? ("Показана цена из поиска. Перелет: " + failMsg) : "Показана цена из поиска. Точный перелет сейчас уточнить не удалось — агент подтвердит при заявке."; statusNodeOk.textContent = warnText; context.fareNote = "Цена ориентировочная"; syncFareWarningUi(body, context, "warn"); var fareNoteWarn = body.querySelector(".etw-fare-warning__note"); if (fareNoteWarn) { fareNoteWarn.textContent = context.fareNote; } } } } else if (statusNodeOk) { hideFlightAlternatives(body, context, { failed: true, message: "не удалось обновить цену и варианты перелета" }); attachShareTourButton(body, context, item); context.priceExact = false; ensureFallbackTourPrices(context); syncModalPrice(context); context.fareNote = "Не удалось обновить цену"; statusNodeOk.className = "etw-actualize-status etw-actualize-status_warn"; statusNodeOk.textContent = "Цена может измениться. Отправьте заявку — агент проверит наличие мест."; syncFareWarningUi(body, context, "warn"); } } catch (e) { stopProgress(); var statusNodeErr = body.querySelector(".etw-actualize-status"); hideFlightAlternatives(body, context, { failed: true, message: "ошибка при уточнении рейса" }); context.priceExact = false; ensureFallbackTourPrices(context); syncModalPrice(context); context.fareNote = "Не удалось обновить цену"; if (statusNodeErr) { statusNodeErr.className = "etw-actualize-status etw-actualize-status_warn"; statusNodeErr.textContent = "Цена может измениться. Отправьте заявку — агент проверит наличие мест."; } syncFareWarningUi(body, context, "warn"); } }; context._rerunActualize = runActualize; bindOptionsCalcControls(body, context, runActualize); await runActualize(); if (!item.hotelUrl) { var loadingNodeNoUrl = body.querySelector(".etw-tour-loading"); if (loadingNodeNoUrl) { loadingNodeNoUrl.textContent = "Для этого тура карточка отеля недоступна."; } return; } try { var payload = await fetchJSON(buildApiUrl("hotelcontent", { url: item.hotelUrl })); var content = payload.data && payload.data.content ? payload.data.content : {}; var loadingNode = body.querySelector(".etw-tour-loading"); if (loadingNode) { loadingNode.outerHTML = renderHotelInfoBlock(content); } var mainImg = body.querySelector(".etw-tour-main img"); body.querySelectorAll(".etw-tour-thumb").forEach(function (btn) { btn.addEventListener("click", function () { var src = btn.getAttribute("data-src"); if (mainImg && src) { mainImg.src = src; } }); }); } catch (error) { var loadingNodeErr = body.querySelector(".etw-tour-loading"); if (loadingNodeErr) { loadingNodeErr.textContent = "Не удалось загрузить описание отеля."; } } } function setLoading(isLoading, progress) { loadingBox.classList.toggle("sf-searchresult__loading_hidden", !isLoading); progressNode.textContent = progress || "0%"; } function showDateHints() { if (dateHintsNode) { dateHintsNode.classList.remove("etw-date-hints_hidden"); if (calendarAvailability) { if (lastCheckinList) { calendarAvailability.updateDates(lastCheckinList); calendarAvailability.setRange(dateFromNode.value, dateToNode.value); } else { dateHintsNode.innerHTML = '
Загружаем календарь...
'; refreshControls().catch(function () {}); } } } } function hideDateHints() { if (dateHintsNode) { dateHintsNode.classList.add("etw-date-hints_hidden"); } } function toIsoDate(value) { if (!value) { return ""; } var raw = String(value).trim(); var m = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (m) { return m[3] + "-" + m[2] + "-" + m[1]; } var date = new Date(raw); if (isNaN(date.getTime())) { return raw; } return date.toISOString().slice(0, 10); } async function loadRates() { try { var ratesPayloads = await Promise.all([ fetchJSON(buildApiUrl("rates", { BASE_ALIAS: "USD" })), fetchJSON(buildApiUrl("rates", { BASE_ALIAS: "EUR" })) ]); var usdData = ratesPayloads[0] && ratesPayloads[0].data ? ratesPayloads[0].data : {}; var eurData = ratesPayloads[1] && ratesPayloads[1].data ? ratesPayloads[1].data : {}; var usdToRub = usdData.rates && usdData.rates.RUB ? Number(usdData.rates.RUB) : null; var eurToRub = eurData.rates && eurData.rates.RUB ? Number(eurData.rates.RUB) : null; if (rateUsdNode) { rateUsdNode.textContent = Number.isFinite(usdToRub) ? usdToRub.toFixed(2) : "-"; } if (rateEurNode) { rateEurNode.textContent = Number.isFinite(eurToRub) ? eurToRub.toFixed(2) : "-"; } if (ratesDateNode) { ratesDateNode.textContent = usdData.date || eurData.date || "-"; } } catch (error) { if (rateUsdNode) { rateUsdNode.textContent = "-"; } if (rateEurNode) { rateEurNode.textContent = "-"; } if (ratesDateNode) { ratesDateNode.textContent = "-"; } } } async function loadStat() { await sendStatEvent("start"); } async function sendStatEvent(eventType) { try { await fetchJSON( buildApiUrl("stat", { host: statHost, agentEmail: agentEmail || "", widgetName: widgetName, widgetVersion: widgetVersion, event: eventType || "start" }) ); } catch (error) { // Stat collection should not break search widget startup. } } function setDatesFromMeta(data) { var today = new Date(); var nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); if (!dateFromNode.value) { dateFromNode.value = nextWeek.toISOString().slice(0, 10); } if (!dateToNode.value) { dateToNode.value = new Date(nextWeek.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); } if (data && data.base && data.base.CHECKIN_BEG) { dateFromNode.value = data.base.CHECKIN_BEG; } if (data && data.base && data.base.CHECKIN_END) { dateToNode.value = data.base.CHECKIN_END; } } async function loadMeta() { var payload = await fetchJSON(buildApiUrl("meta", {})); var data = payload.data || {}; fullTownFroms = data.townFroms || data.TOWNFROMS || []; buildCountryFromOptions(fullTownFroms); filterTownFromsByCountryFrom(); syncDepartureFieldPresentation(); fillSelect(countryNode, data.states || data.STATES || [], "Любая"); // Страну тура фиксируем до первого controls — иначе тип тура/туры приходят пустыми. if (defaultTourCountry && selectHasOption(countryNode, defaultTourCountry)) { countryNode.value = String(defaultTourCountry); } else if (data.base && data.base.STATEINC && selectHasOption(countryNode, data.base.STATEINC)) { countryNode.value = String(data.base.STATEINC); } else { var metaSelectedState = (data.states || data.STATES || []).find(function (item) { return Number(item.selected) === 1; }); if (metaSelectedState && blacklistTourCountries.indexOf(String(metaSelectedState.id || "")) < 0) { countryNode.value = String(metaSelectedState.id); } } updateFormstoneSelect(countryNode, true); tourTypeCatalog = data.tourTypes || data.TOUR_TYPES || []; fillSelect(tourTypeNode, tourTypeCatalog, "Любой"); fillSelect(tourNode, data.tours || data.TOURS || [], "Любой"); fillMultiSelect(mealNode, data.meals || data.MEALS || [], []); fillNightsFromObject(data.nights || []); setDatesFromMeta(data); if (data.base) { if (data.base.TOWNFROMINC) { var baseTownId = String(data.base.TOWNFROMINC); var selectedTown = fullTownFroms.find(function (town) { return String(town.id) === baseTownId; }); // Не перетираем уже выбранную страну вылета (например Россия) // каталожным base из другой страны (часто Ереван) — иначе value // города становится невалидным и Formstone врёт подписью. if (selectedTown && selectedTown.stateFromKey && !countryFromNode.value) { countryFromNode.value = String(selectedTown.stateFromKey); filterTownFromsByCountryFrom(); } if (!defaultDepartureCity && selectHasOption(depCityNode, baseTownId)) { depCityNode.value = baseTownId; updateFormstoneSelect(depCityNode, true); } syncDepartureFieldPresentation(); } } ensureDepartureCitySelected(); await refreshControls(); refreshAllFormstoneSelects(false); ensureDepartureCitySelected(); syncDepartureFieldPresentation(); } async function executeSearch(options) { options = options || {}; if (!options.skipStat) { sendStatEvent("search"); } var result = await fetchSearchResults(options); if (!options.skipRender) { renderResults(result.items, result.pager); } return result; } async function startSearch(event) { if (event) { event.preventDefault(); } currentSearchPage = 1; searchButton.disabled = true; progressTextNode.style.display = ""; setLoading(true, "25%"); tableContainer.innerHTML = ""; try { setLoading(true, "70%"); await executeSearch(); setLoading(true, "100%"); } catch (error) { tableContainer.innerHTML = '

Ошибка загрузки данных: ' + error.message + "

"; } finally { setTimeout(function () { setLoading(false, "100%"); }, 500); searchButton.disabled = false; } } form.addEventListener("submit", startSearch); tableContainer.addEventListener("click", function (event) { var pageBtn = event.target.closest(".etw-search-pager__btn"); if (pageBtn) { event.preventDefault(); var page = Number(pageBtn.getAttribute("data-page") || 1); loadSearchPage(page).catch(function () {}); return; } var priceBtn = event.target.closest(".etw-price-btn"); if (priceBtn) { event.preventDefault(); var resultKey = priceBtn.getAttribute("data-result-key") || ""; var item = renderResultMap[resultKey]; if (item) { openTourModal(item); } return; } var link = event.target.closest(".etw-hotel-link"); if (!link) { return; } event.preventDefault(); var encodedUrl = link.getAttribute("data-hotel-url") || ""; var encodedTitle = link.getAttribute("data-hotel-title") || ""; var hotelUrl = ""; var hotelTitle = ""; try { hotelUrl = decodeURIComponent(encodedUrl); } catch (err) { hotelUrl = encodedUrl; } try { hotelTitle = decodeURIComponent(encodedTitle); } catch (err2) { hotelTitle = encodedTitle; } if (hotelUrl) { openHotelModal(hotelUrl, hotelTitle); } }); bindCascadeSelectHandlers(); Promise.all([loadMeta(), loadRates(), loadStat()]) .then(function () { initFormstoneInputs(); setAdvancedFiltersVisible(defaultAdvancedOpen); updateChildAgeState(); bindCascadeSelectHandlers(); refreshAllFormstoneSelects(false); return tryRestoreFromShareHash(); }) .then(function () { refreshAllFormstoneSelects(false); syncDepartureFieldPresentation(); }) .catch(function (error) { tableContainer.innerHTML = '

Не удалось загрузить справочники: ' + error.message + "

"; }); hotelFilterNode.addEventListener("input", function () { hideDateHints(); filterHotelOptions(); }); if (advancedFiltersNode) { advancedFiltersNode.addEventListener("change", function () { setAdvancedFiltersVisible(advancedFiltersNode.checked); if (advancedFiltersNode.checked) { refreshControls().catch(function () {}); } }); if (jq && jq.fn) { jq(advancedFiltersNode).on("change.etours", function () { setAdvancedFiltersVisible(advancedFiltersNode.checked); if (advancedFiltersNode.checked) { refreshControls().catch(function () {}); } }); } } childsNode.addEventListener("change", function () { hideDateHints(); updateChildAgeState(); }); if (jq && jq.fn) { jq(childsNode).on("change.etours", function () { updateChildAgeState(); }); } Array.prototype.forEach.call(priceModeNodes, function (node) { node.addEventListener("change", function () { hideDateHints(); }); }); dateFromNode.addEventListener("change", function () { showDateHints(); if (calendarAvailability) { calendarAvailability.setRange(dateFromNode.value, dateToNode.value); } refreshControls().catch(function () {}); }); dateFromNode.addEventListener("focus", showDateHints); dateFromNode.addEventListener("click", showDateHints); dateToNode.addEventListener("change", function () { showDateHints(); if (calendarAvailability) { calendarAvailability.setRange(dateFromNode.value, dateToNode.value); } refreshControls().catch(function () {}); }); dateToNode.addEventListener("focus", showDateHints); dateToNode.addEventListener("click", showDateHints); document.addEventListener("click", function (event) { if (!wrapper || !dateHintsNode) { return; } var t = event.target; var path = typeof event.composedPath === "function" ? event.composedPath() : []; if (path.length && (path.indexOf(dateHintsNode) >= 0 || path.indexOf(dateFromNode) >= 0 || path.indexOf(dateToNode) >= 0)) { return; } if (t === dateFromNode || t === dateToNode) { return; } if (dateHintsNode.contains(t)) { return; } hideDateHints(); }); wrapper.addEventListener("focusin", function (event) { var t = event.target; if (!t) { return; } if (t === dateFromNode || t === dateToNode) { return; } if (dateHintsNode && dateHintsNode.contains(t)) { return; } hideDateHints(); }); })();