(() => { "use strict"; const config = window.SALON; const $ = (selector, parent = document) => parent.querySelector(selector); const create = (tag, className, text) => { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; }; const formatPrice = value => value.replace(/ /g, "\u00a0") + "\u00a0₽"; const safeUrl = value => { try { const url = new URL(value); return url.protocol === "https:" ? url.href : null; } catch { return null; } }; const escapeHtml = text => String(text).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const label = (key, fallback) => config.buttons[key]?.label || fallback; const buttonUrl = value => { if (/^#[a-zA-Z0-9_-]*$/.test(value) || value === 'price.php') return value; try {const u=new URL(value);return ['https:','mailto:','tel:'].includes(u.protocol)?u.href:null;} catch{return null;} }; document.querySelectorAll('[data-control]').forEach(element => { const item=config.buttons[element.dataset.control]; if(!item) return; element.textContent=item.label; if(element.hasAttribute('aria-label')) element.setAttribute('aria-label',item.label.replace(/\n/g,' ')); if(element.classList.contains('round-booking')) element.style.whiteSpace='pre-line'; if(element.tagName==='A') element.href=buttonUrl(item.url)||'#'; if(element.hasAttribute('data-book') && item.mode==='url') { element.removeAttribute('data-book'); element.addEventListener('click',()=>{const url=buttonUrl(item.url);if(url) location.assign(url);}); } }); $('#booking-continue').textContent=label('continueBook','Перейти к записи'); $('#retry-map').textContent=label('retryMap','Загрузить карту'); let currentService = null; let currentBranch = null; let lastTrigger = null; const dialog = $("#booking-dialog"); const tabs = $(".category-tabs"); const content = $("#price-content"); const setCategory = index => { [...tabs.children].forEach((tab, i) => { tab.setAttribute("aria-selected", String(i === index)); tab.tabIndex = i === index ? 0 : -1; content.children[i].hidden = i !== index; }); }; config.categories.forEach((category, index) => { const tab = create("button", "category-tab"); tab.type = "button"; tab.id = `category-${index}`; tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", `panel-${index}`); tab.append(create("span", "", category.name), create("span", "tab-number", String(category.services.length).padStart(2, "0"))); tab.addEventListener("click", () => setCategory(index)); tab.addEventListener("keydown", event => { let next; if (["ArrowDown", "ArrowRight"].includes(event.key)) next = (index + 1) % config.categories.length; if (["ArrowUp", "ArrowLeft"].includes(event.key)) next = (index - 1 + config.categories.length) % config.categories.length; if (event.key === "Home") next = 0; if (event.key === "End") next = config.categories.length - 1; if (next !== undefined) { event.preventDefault(); setCategory(next); tabs.children[next].focus(); } }); tabs.append(tab); const panel = create("section", "price-panel"); panel.id = `panel-${index}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id); const heading = create("div", "panel-title"); heading.append(create("h3", "", category.name), create("p", "", category.note)); panel.append(heading); category.services.forEach(([name, price]) => { const row = create("div", "service-row"); const button = create("button", "service-book", label("serviceBook", "Записаться")); button.type = "button"; button.setAttribute("aria-label", `${label("serviceBook", "Записаться")}: ${name}`); button.title = `${label("serviceBook", "Записаться")}: ${name}`; button.addEventListener("click", () => openBooking({name, price}, null, button)); row.append(create("span", "service-name", name), create("span", "service-price", formatPrice(price)), button); panel.append(row); }); content.append(panel); }); setCategory(0); const tabsMedia = matchMedia("(max-width:760px)"); const updateOrientation = () => tabs.setAttribute("aria-orientation", tabsMedia.matches ? "horizontal" : "vertical"); updateOrientation(); tabsMedia.addEventListener("change", updateOrientation); function updateBooking() { const branch = config.branches.find(item => item.key === currentBranch); const serviceLink = branch && currentService && branch.serviceUrls[currentService.name]; const url = branch && (safeUrl(serviceLink) || safeUrl(branch.bookingUrl)); const next = $("#booking-continue"); next.setAttribute("aria-disabled", String(!url)); if (url) { next.href = url; next.removeAttribute("tabindex"); $("#booking-status").textContent = serviceLink ? "Выбранная услуга откроется в сервисе записи. Выберите там удобные дату и время." : currentService ? "В сервисе записи выберите эту услугу, удобные дату и время." : "Перейдите в сервис записи, чтобы выбрать услугу, дату и время."; } else { next.removeAttribute("href"); next.tabIndex = -1; $("#booking-status").textContent = branch ? "Онлайн-запись в этот филиал пока недоступна. Пожалуйста, попробуйте позже." : "Выберите удобный филиал, чтобы проверить доступность онлайн-записи."; } } function openBooking(service, branchKey, trigger) { lastTrigger = trigger; currentService = service; currentBranch = branchKey; const selected = $("#selected-service"); selected.replaceChildren(); selected.hidden = !service; if (service) selected.append(create("strong", "", "ВЫБРАННАЯ УСЛУГА"), create("span", "", `${service.name} · ${formatPrice(service.price)}`)); $("#booking-branches").querySelectorAll("input").forEach(input => { input.checked = input.value === branchKey; }); updateBooking(); dialog.showModal(); } document.querySelectorAll("[data-book]").forEach(button => button.addEventListener("click", () => openBooking(null, null, button))); $(".close-button").addEventListener("click", () => dialog.close()); dialog.addEventListener("click", event => { const rect = dialog.getBoundingClientRect(); if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) dialog.close(); }); dialog.addEventListener("close", () => lastTrigger?.focus()); $("#booking-continue").addEventListener("click", event => { if (event.currentTarget.getAttribute("aria-disabled") === "true") event.preventDefault(); }); config.branches.forEach((branch, index) => { const card = create("article", "branch-card"); card.append(create("span", "branch-number", `ФИЛИАЛ / 0${index + 1}`), create("h3", "", branch.address)); if (branch.mapNote) card.append(create("p", "map-note", branch.mapNote)); const links = create("div", "branch-links"); const mapLink = create("a", "", label("mapLink", "Открыть в Яндекс Картах")); mapLink.href = branch.mapsUrl; const routeLink = create("a", "", label("routeLink", "Построить маршрут")); const routeTarget = branch.coordinates ? branch.coordinates.join(",") : branch.fullAddress; routeLink.href = branch.routeUrl || "https://yandex.ru/maps/?rtext=~" + encodeURIComponent(routeTarget) + "&rtt=auto"; [mapLink, routeLink].forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; }); links.append(mapLink, routeLink); const book = create("button", "branch-book"); book.textContent = label("branchBook", "Записаться в этот филиал"); book.addEventListener("click", () => openBooking(null, branch.key, book)); card.append(links, book); $("#branch-cards").append(card); const choice = create("label", "branch-choice"); const radio = create("input"); radio.type = "radio"; radio.name = "branch"; radio.value = branch.key; radio.addEventListener("change", () => { currentBranch = radio.value; updateBooking(); }); choice.append(radio, create("span", "", branch.address)); $("#booking-branches").append(choice); }); let mapLoading = false; let mapInstance; function loadMap() { if (mapLoading || mapInstance) return; mapLoading = true; const script = document.createElement("script"); script.src = "https://api-maps.yandex.ru/2.1/?lang=ru_RU" + (config.yandexApiKey ? "&apikey=" + encodeURIComponent(config.yandexApiKey) : ""); script.async = true; let timer; const fail = () => { clearTimeout(timer); mapLoading = false; $("#map-shell").classList.remove("map-loaded"); $("#retry-map").hidden = false; }; timer = setTimeout(fail, 15000); script.onerror = fail; script.onload = () => { if (!window.ymaps) { fail(); return; } window.ymaps.ready(() => { try { const points = config.branches.filter(branch => Array.isArray(branch.coordinates)); if (!points.length) { fail(); return; } mapInstance = new window.ymaps.Map("map", { center: points[0].coordinates, zoom: 14, controls: ["zoomControl", "fullscreenControl"] }); points.forEach((branch, index) => { const placemark = new window.ymaps.Placemark(branch.coordinates, { balloonContentHeader: escapeHtml(config.name), balloonContentBody: escapeHtml(branch.address) + (branch.mapNote ? "
" + escapeHtml(branch.mapNote) : ""), hintContent: `${config.name} — ${branch.address}`, iconContent: String(index + 1) }, {preset: "islands#circleIcon", iconColor: "#602631"}); mapInstance.geoObjects.add(placemark); }); if (points.length > 1) mapInstance.setBounds(mapInstance.geoObjects.getBounds(), {checkZoomRange: true, zoomMargin: 65}); mapInstance.behaviors.disable("scrollZoom"); clearTimeout(timer); $("#map-shell").classList.add("map-loaded"); } catch { mapInstance = null; fail(); } }); }; document.head.append(script); } $("#retry-map").addEventListener("click", loadMap); if ("IntersectionObserver" in window) { const observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) { loadMap(); observer.disconnect(); } }, {rootMargin: "350px"}); observer.observe($("#map-shell")); } else loadMap(); })();
Made on
Tilda