Загрузил проект на гит сурс
This commit is contained in:
@@ -0,0 +1,897 @@
|
||||
function getCurrentPageName() {
|
||||
const normalizedPath = window.location.pathname.replace(/\/+$/, "");
|
||||
const raw = (normalizedPath.split("/").pop() || "").toLowerCase();
|
||||
const normalized = raw.replace(/\.html$/, "");
|
||||
return normalized || "index";
|
||||
}
|
||||
|
||||
const isLocalDevHost =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1";
|
||||
|
||||
function routeHref(page) {
|
||||
if (page === "index") {
|
||||
return isLocalDevHost ? "index.html" : "/";
|
||||
}
|
||||
return isLocalDevHost ? `${page}.html` : `/${page}`;
|
||||
}
|
||||
|
||||
function renderSharedLayout() {
|
||||
const currentPage = getCurrentPageName();
|
||||
const headerMount = document.querySelector("[data-site-header]");
|
||||
const footerMount = document.querySelector("[data-site-footer]");
|
||||
|
||||
if (headerMount) {
|
||||
const navItems = [
|
||||
{ page: "prices", href: routeHref("prices"), label: "Цены" },
|
||||
{ page: "portfolio", href: routeHref("portfolio"), label: "Портфолио" },
|
||||
{ page: "reviews", href: routeHref("reviews"), label: "Отзывы" },
|
||||
];
|
||||
|
||||
const navHtml = navItems
|
||||
.map((item) => {
|
||||
const isCurrent = currentPage === item.page;
|
||||
return `<a class="header-link${isCurrent ? " header-link--current" : ""}" href="${item.href}"${isCurrent ? ' aria-current="page"' : ""}>${item.label}</a>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
headerMount.outerHTML = `
|
||||
<header class="header">
|
||||
<div class="container header__inner">
|
||||
<div class="header__left">
|
||||
<a href="${routeHref("index")}" class="logo">
|
||||
<img src="image/logo-test.jpg" alt="Фриз">
|
||||
</a>
|
||||
<nav class="header__nav" aria-label="Основная навигация">
|
||||
${navHtml}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header__actions">
|
||||
<div class="header__contact">
|
||||
<a class="phone-link" href="tel:+79217412646">+7 (921) 741-26-46</a>
|
||||
<span class="header__meta">Ежедневно 10:00-18:00</span>
|
||||
<span class="header__geo">Санкт-Петербург</span>
|
||||
</div>
|
||||
<a class="btn btn--outline" href="https://vk.com/ya_lo_ya" target="_blank" rel="noopener noreferrer">VK</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
if (footerMount) {
|
||||
const isPrivacyPage = currentPage === "privacy";
|
||||
const isRequisitesPage = currentPage === "requisites";
|
||||
|
||||
footerMount.outerHTML = `
|
||||
<a class="floating-call" href="tel:+79217412646" aria-label="Позвонить">Позвонить</a>
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<div class="footer__brand">
|
||||
<p class="footer__copy">© <span id="year"></span> Фриз. Все права защищены.</p>
|
||||
<p class="footer__ip">ИП Погребнякова Ольга Юрьевна</p>
|
||||
<p class="footer__geo">Работаем в Санкт-Петербурге и Ленинградской области</p>
|
||||
</div>
|
||||
<div class="footer__legal">
|
||||
<a class="footer__link" href="${routeHref("requisites")}"${isRequisitesPage ? ' aria-current="page"' : ""}>Реквизиты ИП</a>
|
||||
<a class="footer__link" href="${routeHref("privacy")}"${isPrivacyPage ? ' aria-current="page"' : ""}>Политика обработки персональных данных</a>
|
||||
</div>
|
||||
<p class="footer__disclaimer">Информация на сайте не является публичной офертой и носит информационный характер</p>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
}
|
||||
|
||||
renderSharedLayout();
|
||||
|
||||
const currentYear = document.getElementById("year");
|
||||
if (currentYear) {
|
||||
currentYear.textContent = new Date().getFullYear();
|
||||
}
|
||||
|
||||
const reduceMotionMql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
function easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
function smoothScrollTo(targetY) {
|
||||
const startY = window.scrollY || document.documentElement.scrollTop;
|
||||
const change = targetY - startY;
|
||||
if (reduceMotionMql.matches) {
|
||||
window.scrollTo(0, targetY);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(change) < 2) {
|
||||
return;
|
||||
}
|
||||
const duration = Math.min(980, Math.max(480, Math.abs(change) * 0.62));
|
||||
const start = performance.now();
|
||||
|
||||
const step = (now) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
window.scrollTo(0, startY + change * easeInOutCubic(t));
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (document.documentElement.classList.contains("modal-open")) {
|
||||
return;
|
||||
}
|
||||
const a = e.target.closest("a");
|
||||
if (!a) {
|
||||
return;
|
||||
}
|
||||
const href = a.getAttribute("href");
|
||||
if (!href || href === "#" || href.charAt(0) !== "#") {
|
||||
return;
|
||||
}
|
||||
if (a.hasAttribute("download")) {
|
||||
return;
|
||||
}
|
||||
const el = document.querySelector(href);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const header = document.querySelector(".header");
|
||||
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||
const gap = 16;
|
||||
const y = el.getBoundingClientRect().top + window.scrollY - headerH - gap;
|
||||
smoothScrollTo(Math.max(0, y));
|
||||
history.pushState(null, "", href);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const isHomePage = () => {
|
||||
return getCurrentPageName() === "index";
|
||||
};
|
||||
|
||||
document.querySelectorAll("a.logo").forEach((link) => {
|
||||
const href = (link.getAttribute("href") || "").trim();
|
||||
const goesHome =
|
||||
href === "index.html" ||
|
||||
href === "./index.html" ||
|
||||
href === "/" ||
|
||||
href === "/index" ||
|
||||
href.endsWith("/index.html");
|
||||
if (!goesHome) {
|
||||
return;
|
||||
}
|
||||
link.addEventListener("click", (event) => {
|
||||
if (!isHomePage()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (window.scrollY > 0) {
|
||||
smoothScrollTo(0);
|
||||
}
|
||||
if (window.location.hash) {
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById("contactForm");
|
||||
if (form) {
|
||||
form.setAttribute("novalidate", "novalidate");
|
||||
const nameInput = form.querySelector('input[name="name"]');
|
||||
const phoneInput = form.querySelector('input[name="phone"]');
|
||||
const honeypotInput = form.querySelector('input[name="contact_fax"]');
|
||||
const startedAtInput = form.querySelector('input[name="started_at"]');
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
|
||||
if (startedAtInput) {
|
||||
startedAtInput.value = String(Date.now());
|
||||
}
|
||||
|
||||
if (phoneInput) {
|
||||
const formatPhone = (rawValue) => {
|
||||
const digits = rawValue.replace(/\D/g, "");
|
||||
const localDigits = digits.startsWith("7") ? digits.slice(1) : digits;
|
||||
const trimmed = localDigits.slice(0, 10);
|
||||
|
||||
if (!trimmed.length) {
|
||||
return "+7";
|
||||
}
|
||||
|
||||
let result = "+7";
|
||||
if (trimmed.length > 0) {
|
||||
result += ` (${trimmed.slice(0, 3)}`;
|
||||
}
|
||||
if (trimmed.length >= 3) {
|
||||
result += ")";
|
||||
}
|
||||
if (trimmed.length > 3) {
|
||||
result += ` ${trimmed.slice(3, 6)}`;
|
||||
}
|
||||
if (trimmed.length > 6) {
|
||||
result += `-${trimmed.slice(6, 8)}`;
|
||||
}
|
||||
if (trimmed.length > 8) {
|
||||
result += `-${trimmed.slice(8, 10)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
phoneInput.addEventListener("focus", () => {
|
||||
if (!phoneInput.value.trim()) {
|
||||
phoneInput.value = "+7";
|
||||
} else {
|
||||
phoneInput.value = formatPhone(phoneInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
phoneInput.addEventListener("input", () => {
|
||||
phoneInput.value = formatPhone(phoneInput.value);
|
||||
phoneInput.setSelectionRange(phoneInput.value.length, phoneInput.value.length);
|
||||
});
|
||||
|
||||
phoneInput.addEventListener("keydown", (event) => {
|
||||
const selectionStart = phoneInput.selectionStart;
|
||||
const selectionEnd = phoneInput.selectionEnd;
|
||||
const hasSelection =
|
||||
selectionStart !== null &&
|
||||
selectionEnd !== null &&
|
||||
selectionStart !== selectionEnd;
|
||||
const fullSelection =
|
||||
selectionStart === 0 && selectionEnd === phoneInput.value.length;
|
||||
|
||||
if (
|
||||
fullSelection &&
|
||||
(event.key === "Backspace" || event.key === "Delete")
|
||||
) {
|
||||
event.preventDefault();
|
||||
phoneInput.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(event.key === "Backspace" || event.key === "Delete") &&
|
||||
selectionStart !== null &&
|
||||
selectionStart <= 2 &&
|
||||
!hasSelection
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
phoneInput.addEventListener("blur", () => {
|
||||
if (phoneInput.value === "+7" || phoneInput.value === "+7 (") {
|
||||
phoneInput.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const consentInput = form.querySelector('input[name="consent"]');
|
||||
const consentLabel = form.querySelector(".contact-form__consent");
|
||||
const nameValidationRegex = /^[A-Za-zА-Яа-яЁё\s-]{2,80}$/;
|
||||
|
||||
const showFormToast = (message, variant = "warning") => {
|
||||
let toast = document.getElementById("formToast");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = "formToast";
|
||||
toast.className = "form-toast";
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.classList.remove("form-toast--warning", "form-toast--success");
|
||||
toast.classList.add(`form-toast--${variant}`);
|
||||
toast.setAttribute("role", variant === "success" ? "status" : "alert");
|
||||
toast.setAttribute("aria-live", "polite");
|
||||
toast.classList.add("is-visible");
|
||||
clearTimeout(toast._hideTimer);
|
||||
const hideMs = variant === "success" ? 6000 : 5000;
|
||||
toast._hideTimer = window.setTimeout(() => {
|
||||
toast.classList.remove("is-visible");
|
||||
}, hideMs);
|
||||
toast.onclick = () => {
|
||||
clearTimeout(toast._hideTimer);
|
||||
toast.classList.remove("is-visible");
|
||||
};
|
||||
};
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (nameInput) {
|
||||
const normalizedName = nameInput.value.trim().replace(/\s+/g, " ");
|
||||
nameInput.value = normalizedName;
|
||||
if (!normalizedName) {
|
||||
showFormToast("Пожалуйста, укажите ваше имя", "warning");
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
if (!nameValidationRegex.test(normalizedName)) {
|
||||
showFormToast(
|
||||
"Имя должно содержать только буквы, пробелы и дефис (2-80 символов)",
|
||||
"warning"
|
||||
);
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (phoneInput) {
|
||||
const phoneDigits = phoneInput.value.replace(/\D/g, "");
|
||||
const isPhoneValid = phoneDigits.length === 11 && phoneDigits.startsWith("7");
|
||||
if (!isPhoneValid) {
|
||||
showFormToast("Пожалуйста, укажите корректный номер телефона", "warning");
|
||||
phoneInput.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (consentInput && !consentInput.checked) {
|
||||
consentLabel?.classList.add("contact-form__consent--error");
|
||||
consentLabel?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
showFormToast(
|
||||
"Чтобы отправить заявку, отметьте согласие на обработку персональных данных",
|
||||
"warning"
|
||||
);
|
||||
consentInput.focus();
|
||||
return;
|
||||
}
|
||||
consentLabel?.classList.remove("contact-form__consent--error");
|
||||
|
||||
if (startedAtInput && !startedAtInput.value) {
|
||||
startedAtInput.value = String(Date.now());
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
// Явно чистим honeypot: защищает от автозаполнения браузером.
|
||||
if (honeypotInput) {
|
||||
honeypotInput.value = "";
|
||||
}
|
||||
formData.delete("website");
|
||||
formData.set("contact_fax", "");
|
||||
if (!formData.get("consent")) {
|
||||
formData.set("consent", "1");
|
||||
}
|
||||
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.setAttribute("aria-busy", "true");
|
||||
}
|
||||
|
||||
fetch("submit.php", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
const errorMessage =
|
||||
payload?.message ||
|
||||
"Не удалось отправить заявку. Позвоните нам, пожалуйста, по телефону +7 (921) 741-26-46";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
showFormToast("Спасибо! Заявка принята — скоро мы с вами свяжемся", "success");
|
||||
form.reset();
|
||||
if (startedAtInput) {
|
||||
startedAtInput.value = String(Date.now());
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
showFormToast(error.message, "warning");
|
||||
})
|
||||
.finally(() => {
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.removeAttribute("aria-busy");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (consentInput) {
|
||||
consentInput.addEventListener("change", () => {
|
||||
if (consentInput.checked) {
|
||||
consentLabel?.classList.remove("contact-form__consent--error");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const revealItems = document.querySelectorAll(".reveal");
|
||||
const revealObserver = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
entry.target.classList.add("is-visible");
|
||||
observer.unobserve(entry.target);
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.15,
|
||||
}
|
||||
);
|
||||
|
||||
revealItems.forEach((item) => revealObserver.observe(item));
|
||||
|
||||
const priceAccordion = document.getElementById("priceAccordion");
|
||||
|
||||
const fallbackPriceData = [
|
||||
{
|
||||
title: "Электромонтажные работы",
|
||||
items: [
|
||||
{ name: "Установка розетки", price: "от 650 ₽/шт" },
|
||||
{ name: "Установка выключателя", price: "от 550 ₽/шт" },
|
||||
{ name: "Монтаж подрозетника", price: "от 450 ₽/шт" },
|
||||
{ name: "Монтаж точки освещения", price: "от 1 200 ₽/шт" },
|
||||
{ name: "Монтаж потолочного светильника", price: "от 900 ₽/шт" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Плиточные работы",
|
||||
items: [
|
||||
{ name: "Укладка плитки 20x30", price: "от 350 ₽/шт" },
|
||||
{ name: "Укладка керамогранита 60x60", price: "от 900 ₽/шт" },
|
||||
{ name: "Запил плитки под 45 градусов", price: "от 500 ₽/пог.м" },
|
||||
{ name: "Затирка эпоксидная", price: "от 300 ₽/м²" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Сантехнические работы",
|
||||
items: [
|
||||
{ name: "Установка смесителя", price: "от 1 500 ₽/шт" },
|
||||
{ name: "Монтаж унитаза", price: "от 2 500 ₽/шт" },
|
||||
{ name: "Установка раковины с тумбой", price: "от 3 000 ₽/шт" },
|
||||
{ name: "Подключение полотенцесушителя", price: "от 2 800 ₽/шт" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function escapeHtml(value) {
|
||||
const text = String(value ?? "");
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizePriceItems(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.map((item) => ({
|
||||
name: typeof item?.name === "string" ? item.name : "",
|
||||
price: typeof item?.price === "string" ? item.price : "",
|
||||
amount: typeof item?.amount === "string" ? item.amount : "",
|
||||
unit: typeof item?.unit === "string" ? item.unit : "",
|
||||
total: typeof item?.total === "string" ? item.total : "",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePriceData(data) {
|
||||
if (!Array.isArray(data)) {
|
||||
return JSON.parse(JSON.stringify(fallbackPriceData));
|
||||
}
|
||||
|
||||
return data.map((category) => {
|
||||
const children = Array.isArray(category?.children)
|
||||
? category.children.map((child) => ({
|
||||
title: typeof child?.title === "string" ? child.title : "",
|
||||
items: normalizePriceItems(child?.items),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
title: typeof category?.title === "string" ? category.title : "",
|
||||
items: normalizePriceItems(category?.items),
|
||||
children,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPriceData() {
|
||||
const externalData =
|
||||
typeof window.PRICES_DATA !== "undefined" ? window.PRICES_DATA : null;
|
||||
if (externalData) {
|
||||
return normalizePriceData(externalData);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(fallbackPriceData));
|
||||
}
|
||||
|
||||
function buildPriceList(items) {
|
||||
return items
|
||||
.map(
|
||||
(item) => {
|
||||
const rawPrice = String(item.price || "").trim();
|
||||
const desktopPrice = rawPrice
|
||||
.replace(/\s*\/\s*[^\s]+$/u, "")
|
||||
.trim();
|
||||
const mobilePrice = rawPrice
|
||||
.replace(/^от\s+/iu, "")
|
||||
.replace(/\s*\/\s*[^\s]+$/u, "")
|
||||
.trim();
|
||||
const derivedUnitMatch = rawPrice.match(/\/\s*([^\s]+)$/);
|
||||
const derivedUnit = derivedUnitMatch ? derivedUnitMatch[1] : "";
|
||||
const amountValue = item.amount || "1";
|
||||
const unitValue = item.unit || derivedUnit || "";
|
||||
const normalizedName = String(item.name || "").trim().replace(/\s+/g, " ");
|
||||
const fullName = escapeHtml(normalizedName);
|
||||
return `
|
||||
<li>
|
||||
<span class="price-work-name">${fullName}</span>
|
||||
<span class="price-value">
|
||||
<span class="price-value__full">${escapeHtml(desktopPrice || rawPrice)}</span>
|
||||
<span class="price-value__mobile">${escapeHtml(mobilePrice || rawPrice)}</span>
|
||||
</span>
|
||||
<span>${escapeHtml(amountValue)}</span>
|
||||
<span>${escapeHtml(unitValue || "—")}</span>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildPriceTable(items) {
|
||||
return `
|
||||
<ul class="price-list">
|
||||
<li class="price-list__head" aria-hidden="true">
|
||||
<span>Виды работ</span>
|
||||
<span>Цена</span>
|
||||
<span>Кол-во</span>
|
||||
<span>Ед.изм</span>
|
||||
</li>
|
||||
${buildPriceList(Array.isArray(items) ? items : [])}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function slugifyCategory(title) {
|
||||
const map = {
|
||||
а: "a",
|
||||
б: "b",
|
||||
в: "v",
|
||||
г: "g",
|
||||
д: "d",
|
||||
е: "e",
|
||||
ё: "e",
|
||||
ж: "zh",
|
||||
з: "z",
|
||||
и: "i",
|
||||
й: "y",
|
||||
к: "k",
|
||||
л: "l",
|
||||
м: "m",
|
||||
н: "n",
|
||||
о: "o",
|
||||
п: "p",
|
||||
р: "r",
|
||||
с: "s",
|
||||
т: "t",
|
||||
у: "u",
|
||||
ф: "f",
|
||||
х: "h",
|
||||
ц: "ts",
|
||||
ч: "ch",
|
||||
ш: "sh",
|
||||
щ: "sch",
|
||||
ъ: "",
|
||||
ы: "y",
|
||||
ь: "",
|
||||
э: "e",
|
||||
ю: "yu",
|
||||
я: "ya",
|
||||
};
|
||||
|
||||
return String(title || "")
|
||||
.toLowerCase()
|
||||
.split("")
|
||||
.map((ch) => map[ch] ?? ch)
|
||||
.join("")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function openPriceCategoryFromHash() {
|
||||
if (!priceAccordion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = decodeURIComponent(location.hash.replace(/^#/, "")).trim();
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = priceAccordion.querySelector(
|
||||
`.price-category#${CSS.escape(hash)}`
|
||||
);
|
||||
if (!(category instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = category.querySelector(".price-category__toggle");
|
||||
const listWrap = toggle?.nextElementSibling;
|
||||
if (
|
||||
toggle instanceof HTMLElement &&
|
||||
listWrap instanceof HTMLElement &&
|
||||
listWrap.classList.contains("price-list-wrap")
|
||||
) {
|
||||
toggle.setAttribute("aria-expanded", "true");
|
||||
listWrap.hidden = false;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const header = document.querySelector(".header");
|
||||
const offset =
|
||||
header instanceof HTMLElement ? header.getBoundingClientRect().height + 12 : 12;
|
||||
const top = category.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
|
||||
function renderPriceAccordion(priceData) {
|
||||
if (!priceAccordion) {
|
||||
return;
|
||||
}
|
||||
|
||||
priceAccordion.innerHTML = priceData
|
||||
.map((category) => {
|
||||
const hasChildren =
|
||||
Array.isArray(category.children) && category.children.length > 0;
|
||||
const categoryId = slugifyCategory(category.title || "kategoriya");
|
||||
|
||||
if (hasChildren) {
|
||||
const childrenHtml = category.children
|
||||
.map(
|
||||
(child) => `
|
||||
<article class="price-subcategory">
|
||||
<button class="price-subcategory__toggle" type="button" aria-expanded="false">
|
||||
${escapeHtml(child.title || "Подкатегория")}
|
||||
</button>
|
||||
<div class="price-list-wrap" hidden>
|
||||
${buildPriceTable(child.items)}
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<article class="price-category" id="${escapeHtml(categoryId)}">
|
||||
<button class="price-category__toggle" type="button" aria-expanded="false">
|
||||
${escapeHtml(category.title || "Категория")}
|
||||
</button>
|
||||
<div class="price-list-wrap price-list-wrap--nested" hidden>
|
||||
<div class="price-subcategories">
|
||||
${childrenHtml}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<article class="price-category" id="${escapeHtml(categoryId)}">
|
||||
<button class="price-category__toggle" type="button" aria-expanded="false">
|
||||
${escapeHtml(category.title || "Категория")}
|
||||
</button>
|
||||
<div class="price-list-wrap" hidden>
|
||||
${buildPriceTable(category.items)}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
openPriceCategoryFromHash();
|
||||
}
|
||||
|
||||
if (priceAccordion) {
|
||||
loadPriceData().then(renderPriceAccordion);
|
||||
|
||||
window.addEventListener("hashchange", openPriceCategoryFromHash);
|
||||
|
||||
priceAccordion.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = target.closest(
|
||||
".price-category__toggle, .price-subcategory__toggle"
|
||||
);
|
||||
if (!toggle || !(toggle instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listWrap = toggle.nextElementSibling;
|
||||
if (!(listWrap instanceof HTMLElement) || !listWrap.classList.contains("price-list-wrap")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expanded = toggle.getAttribute("aria-expanded") === "true";
|
||||
toggle.setAttribute("aria-expanded", String(!expanded));
|
||||
listWrap.hidden = expanded;
|
||||
});
|
||||
}
|
||||
|
||||
const LOGO_KEY = "remont-spb-logo";
|
||||
const DEFAULT_LOGO = "logo-interior-1.png";
|
||||
const logoOptions = [
|
||||
{ id: "logo-option-1.png", label: "1" },
|
||||
{ id: "logo-option-2.png", label: "2" },
|
||||
{ id: "logo-option-3.png", label: "3" },
|
||||
{ id: "logo-option-4.png", label: "4" },
|
||||
{ id: "logo-option-6.png", label: "5" },
|
||||
{ id: "logo-right-2.png", label: "6" },
|
||||
{ id: "logo-right-4.png", label: "7" },
|
||||
{ id: "logo-right-5.png", label: "8" },
|
||||
{ id: "logo-simple-1.png", label: "9" },
|
||||
{ id: "logo-simple-3.png", label: "10" },
|
||||
{ id: "logo-interior-1.png", label: "11" },
|
||||
{ id: "logo-interior-6.png", label: "12" },
|
||||
{ id: "logo-interior-8.png", label: "13" },
|
||||
{ id: "logo-interior-9.png", label: "14" },
|
||||
{ id: "logo-flat-2.png", label: "15" },
|
||||
{ id: "logo-flat-3.png", label: "16" },
|
||||
{ id: "logo-flat-5.png", label: "17" },
|
||||
];
|
||||
|
||||
const BG_KEY = "remont-spb-bg";
|
||||
const DEFAULT_BG = "bg-4.png";
|
||||
const bgOptions = [
|
||||
{ id: "none", label: "Без фона" },
|
||||
{ id: "bg-1.png", label: "Фон 1" },
|
||||
{ id: "bg-2.png", label: "Фон 2" },
|
||||
{ id: "bg-3.png", label: "Фон 3" },
|
||||
{ id: "bg-4.png", label: "Фон 4" },
|
||||
{ id: "bg-5.png", label: "Фон 5" },
|
||||
];
|
||||
|
||||
function applyLogo(logoId) {
|
||||
localStorage.setItem(LOGO_KEY, logoId);
|
||||
const logoPath = `image/${logoId}`;
|
||||
|
||||
const logoImgs = document.querySelectorAll(".logo img");
|
||||
logoImgs.forEach((img) => {
|
||||
img.src = logoPath;
|
||||
});
|
||||
}
|
||||
|
||||
function applyBg(bgId) {
|
||||
localStorage.setItem(BG_KEY, bgId);
|
||||
const heroBg = document.getElementById("heroBg");
|
||||
const heroSection = document.getElementById("hero");
|
||||
|
||||
if (heroBg && heroSection) {
|
||||
if (bgId === "none") {
|
||||
heroBg.style.display = "none";
|
||||
heroBg.src = "";
|
||||
} else {
|
||||
heroBg.style.display = "block";
|
||||
heroBg.src = `image/${bgId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrandingDefaults() {
|
||||
// Применяем зафиксированные значения брендинга без UI-переключателя.
|
||||
applyLogo(DEFAULT_LOGO);
|
||||
applyBg(DEFAULT_BG);
|
||||
}
|
||||
|
||||
applyBrandingDefaults();
|
||||
|
||||
/* Portfolio Gallery Logic */
|
||||
const workCards = document.querySelectorAll('.work-card[data-images]');
|
||||
const portfolioModal = document.getElementById('portfolioModal');
|
||||
const portfolioTrack = document.getElementById('portfolioTrack');
|
||||
|
||||
if (portfolioModal && portfolioTrack) {
|
||||
const closeButtons = portfolioModal.querySelectorAll('[data-close]');
|
||||
|
||||
const openModal = (images) => {
|
||||
portfolioTrack.innerHTML = images
|
||||
.map(
|
||||
(src) =>
|
||||
`<div class="portfolio-modal__slide"><img src="${src}" alt="Фото объекта"></div>`
|
||||
)
|
||||
.join('');
|
||||
portfolioModal.classList.add('is-open');
|
||||
document.documentElement.classList.add('modal-open');
|
||||
document.body.classList.add('modal-open');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
portfolioModal.classList.remove('is-open');
|
||||
document.documentElement.classList.remove('modal-open');
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
|
||||
workCards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
try {
|
||||
const images = JSON.parse(card.dataset.images);
|
||||
if (images && images.length) {
|
||||
openModal(images);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка при чтении картинок галереи', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
closeButtons.forEach(btn => btn.addEventListener('click', closeModal));
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && portfolioModal.classList.contains('is-open')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Логика перетаскивания (Drag-to-scroll)
|
||||
let isDown = false;
|
||||
let startX;
|
||||
let scrollLeft;
|
||||
|
||||
portfolioTrack.addEventListener('mousedown', (e) => {
|
||||
isDown = true;
|
||||
portfolioTrack.classList.add('is-dragging');
|
||||
startX = e.pageX - portfolioTrack.offsetLeft;
|
||||
scrollLeft = portfolioTrack.scrollLeft;
|
||||
});
|
||||
|
||||
portfolioTrack.addEventListener('mouseleave', () => {
|
||||
isDown = false;
|
||||
portfolioTrack.classList.remove('is-dragging');
|
||||
});
|
||||
|
||||
portfolioTrack.addEventListener('mouseup', () => {
|
||||
isDown = false;
|
||||
portfolioTrack.classList.remove('is-dragging');
|
||||
});
|
||||
|
||||
portfolioTrack.addEventListener('mousemove', (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - portfolioTrack.offsetLeft;
|
||||
const walk = (x - startX) * 2; // Скорость прокрутки при перетаскивании
|
||||
portfolioTrack.scrollLeft = scrollLeft - walk;
|
||||
});
|
||||
|
||||
// Прокрутка колесиком мыши (Wheel scroll)
|
||||
portfolioTrack.addEventListener('wheel', (e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
portfolioTrack.scrollLeft += e.deltaY;
|
||||
}
|
||||
});
|
||||
|
||||
// Блокируем прокрутку страницы, пока открыта модалка
|
||||
portfolioModal.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
if (!portfolioModal.classList.contains('is-open')) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user