Law/app/web/landing.js
2026-03-03 14:13:59 +03:00

667 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const requestModal = document.getElementById("request-modal");
const accessModal = document.getElementById("access-modal");
const requestOpenButtons = document.querySelectorAll("[data-open-modal]");
const requestCloseButtons = document.querySelectorAll("[data-close-modal]");
const accessOpenButtons = document.querySelectorAll("[data-open-access]");
const accessCloseButtons = document.querySelectorAll("[data-close-access]");
const otpModal = document.getElementById("otp-modal");
const otpCloseButtons = document.querySelectorAll("[data-close-otp]");
const requestForm = document.getElementById("request-form");
const requestStatus = document.getElementById("form-status");
const topicSelect = document.getElementById("topic");
const requestPhoneInput = document.getElementById("phone");
const accessForm = document.getElementById("access-form");
const accessPhoneInput = document.getElementById("access-phone");
const accessEmailInput = document.getElementById("access-email");
const accessHpInput = document.getElementById("access-hp-field");
const accessCodeInput = document.getElementById("access-code");
const accessSendOtpButton = document.getElementById("access-send-otp");
const accessStatus = document.getElementById("access-status");
const otpModalForm = document.getElementById("otp-modal-form");
const otpModalCodeInput = document.getElementById("otp-modal-code");
const otpModalCancelButton = document.getElementById("otp-modal-cancel");
const otpModalStatus = document.getElementById("otp-modal-status");
const otpModalHint = document.getElementById("otp-modal-hint");
const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta");
const quoteWrap = quoteText ? quoteText.closest(".consultation-quote") : null;
const featuredTeamSection = document.getElementById("team");
const featuredTeamTrack = document.getElementById("featured-team-track");
const featuredTeamDots = document.getElementById("featured-team-dots");
const featuredTeamPrev = document.getElementById("featured-team-prev");
const featuredTeamNext = document.getElementById("featured-team-next");
const requestEmailInput = document.getElementById("email");
const requestHpInput = document.getElementById("request-hp-field");
let otpModalResolver = null;
let lastAccessOtpChannel = "SMS";
let lastCreateOtpChannel = "SMS";
let authConfig = { public_auth_mode: "sms", available_channels: ["SMS"] };
function setStatus(el, message, kind) {
if (!el) return;
el.className = "status";
if (kind === "ok") el.classList.add("ok");
if (kind === "error") el.classList.add("error");
el.textContent = message;
}
async function parseJsonSafe(response) {
try {
return await response.json();
} catch (_) {
return null;
}
}
function apiErrorDetail(data, fallbackMessage) {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
return fallbackMessage;
}
function normalizeEmail(value) {
return String(value || "").trim().toLowerCase();
}
function extractRuPhoneLocalDigits(raw) {
const text = String(raw || "");
const trimmed = text.trim();
const startsWithPlus7 = trimmed.startsWith("+7");
const startsWith8 = trimmed.startsWith("8");
let digits = text.replace(/\D+/g, "");
if (startsWithPlus7 && digits.startsWith("7")) {
digits = digits.slice(1);
} else if (startsWith8 && digits.startsWith("8")) {
digits = digits.slice(1);
} else if (digits.length === 11 && (digits.startsWith("7") || digits.startsWith("8"))) {
digits = digits.slice(1);
}
return digits.slice(0, 10);
}
function formatRuPhone(raw) {
const digits = extractRuPhoneLocalDigits(raw);
if (!digits) return "+7";
const part1 = digits.slice(0, 3);
const part2 = digits.slice(3, 6);
const part3 = digits.slice(6, 8);
const part4 = digits.slice(8, 10);
let out = "+7";
if (part1) out += " (" + part1;
if (part1.length === 3) out += ")";
if (part2) out += " " + part2;
if (part3) out += "-" + part3;
if (part4) out += "-" + part4;
return out;
}
function normalizeRuPhone(raw) {
const digits = extractRuPhoneLocalDigits(raw);
if (digits.length !== 10) return "";
return "+7" + digits;
}
function isValidRuPhone(phone) {
return /^\+7\d{10}$/.test(String(phone || ""));
}
function bindRuPhoneMask(input) {
if (!input) return;
input.addEventListener("focus", () => {
if (!String(input.value || "").trim()) input.value = "+7";
});
input.addEventListener("input", () => {
input.value = formatRuPhone(input.value);
});
input.addEventListener("blur", () => {
const digits = extractRuPhoneLocalDigits(input.value);
if (!digits.length) input.value = "";
});
}
function currentAuthMode() {
return String(authConfig?.public_auth_mode || "sms").trim().toLowerCase();
}
function preferredChannel({ phone, email }) {
const mode = currentAuthMode();
if (mode === "email") return "EMAIL";
if (mode === "sms_or_email") return email ? "EMAIL" : "SMS";
if (mode === "totp") return "";
return "SMS";
}
function otpCodeDeliveryLabel(channel) {
return String(channel || "").toUpperCase() === "EMAIL" ? "Email" : "SMS";
}
function showAuthHints() {
const mode = currentAuthMode();
const emailRequired = mode === "email";
const smsOnly = mode === "sms";
if (accessPhoneInput) accessPhoneInput.required = smsOnly;
if (accessEmailInput) accessEmailInput.required = emailRequired;
if (requestEmailInput) requestEmailInput.required = emailRequired;
}
async function loadAuthConfig() {
try {
const response = await fetch("/api/public/otp/config");
const data = await parseJsonSafe(response);
if (response.ok && data && typeof data === "object") {
authConfig = data;
}
} catch (_) {}
showAuthHints();
}
function openModal(modal) {
if (!modal) return;
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
if (!document.querySelector(".modal-backdrop.open")) {
document.body.classList.remove("modal-open");
}
}
requestOpenButtons.forEach((button) => {
button.addEventListener("click", () => openModal(requestModal));
});
requestCloseButtons.forEach((button) => {
button.addEventListener("click", () => closeModal(requestModal));
});
accessOpenButtons.forEach((button) => {
button.addEventListener("click", async () => {
try {
const response = await fetch("/api/public/requests/my");
if (response.ok) {
window.location.href = "/client.html";
return;
}
} catch (_) {}
setStatus(accessStatus, "", null);
openModal(accessModal);
});
});
accessCloseButtons.forEach((button) => {
button.addEventListener("click", () => closeModal(accessModal));
});
otpCloseButtons.forEach((button) => {
button.addEventListener("click", () => {
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
});
});
[requestModal, accessModal].forEach((modal) => {
if (!modal) return;
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal(modal);
});
});
if (otpModal) {
otpModal.addEventListener("click", (event) => {
if (event.target !== otpModal) return;
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
});
}
function requestOtpCode(hintText) {
return new Promise((resolve) => {
otpModalResolver = resolve;
if (otpModalCodeInput) otpModalCodeInput.value = "";
setStatus(otpModalStatus, "", null);
if (otpModalHint) otpModalHint.textContent = hintText || "Введите OTP-код из SMS.";
openModal(otpModal);
setTimeout(() => {
if (otpModalCodeInput && typeof otpModalCodeInput.focus === "function") otpModalCodeInput.focus();
}, 10);
});
}
if (otpModalCancelButton) {
otpModalCancelButton.addEventListener("click", () => {
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
});
}
if (otpModalForm) {
otpModalForm.addEventListener("submit", (event) => {
event.preventDefault();
const code = String(otpModalCodeInput?.value || "").trim();
if (!code) {
setStatus(otpModalStatus, "Введите OTP-код.", "error");
return;
}
setStatus(otpModalStatus, "", null);
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve(code);
}
closeModal(otpModal);
});
}
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (otpModalResolver && otpModal && otpModal.classList.contains("open")) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
closeModal(requestModal);
closeModal(accessModal);
});
async function loadTopics() {
if (!topicSelect) return;
const fallback = [{ code: "consulting", name: "Консультация" }];
let topics = fallback;
try {
const response = await fetch("/api/public/requests/topics");
const data = await parseJsonSafe(response);
if (response.ok && Array.isArray(data) && data.length > 0) {
topics = data;
}
} catch (_) {}
topicSelect.innerHTML = '<option value="">Выберите тему</option>';
topics.forEach((row) => {
const option = document.createElement("option");
option.value = String(row.code || "");
option.textContent = String(row.name || row.code || "Тема");
topicSelect.appendChild(option);
});
}
async function loadQuotes() {
let quoteTransitionTimer = 0;
const reducedMotion = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const setQuoteContent = (quote) => {
if (!quoteText || !quoteMeta) return;
quoteText.textContent = String(quote?.text || "");
quoteMeta.textContent = [quote?.author, quote?.source].filter(Boolean).join(" • ");
};
const renderQuote = (quote) => {
if (!quoteText || !quoteMeta) return;
if (!quoteWrap || reducedMotion) {
setQuoteContent(quote);
return;
}
if (quoteTransitionTimer) {
clearTimeout(quoteTransitionTimer);
quoteTransitionTimer = 0;
}
quoteWrap.classList.add("is-transitioning");
quoteTransitionTimer = window.setTimeout(() => {
setQuoteContent(quote);
quoteWrap.classList.remove("is-transitioning");
quoteTransitionTimer = 0;
}, 320);
};
try {
const response = await fetch("/api/public/quotes?limit=8&order=random");
if (!response.ok) throw new Error("quotes fetch failed");
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) throw new Error("quotes empty");
let index = 0;
const render = () => {
const quote = items[index % items.length];
renderQuote(quote);
index += 1;
};
render();
if (items.length > 1) setInterval(render, 5500);
} catch (_) {
renderQuote({
text: "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.",
author: "Команда компании",
});
}
}
function renderFeaturedDots(count, activeIndex) {
if (!featuredTeamDots) return;
featuredTeamDots.innerHTML = "";
if (count <= 1) return;
for (let index = 0; index < count; index += 1) {
const button = document.createElement("button");
button.type = "button";
button.className = "carousel-dot" + (index === activeIndex ? " active" : "");
button.setAttribute("aria-label", "Карточка " + (index + 1));
button.addEventListener("click", () => {
const card = featuredTeamTrack?.children?.[index];
if (card && typeof card.scrollIntoView === "function") {
card.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
}
});
featuredTeamDots.appendChild(button);
}
}
function initFeaturedCarouselControls() {
if (!featuredTeamTrack) return;
const scrollByCards = (dir) => {
const card = featuredTeamTrack.querySelector(".featured-card");
const step = card ? card.getBoundingClientRect().width + 14 : 320;
featuredTeamTrack.scrollBy({ left: dir * step, behavior: "smooth" });
};
if (featuredTeamPrev) featuredTeamPrev.addEventListener("click", () => scrollByCards(-1));
if (featuredTeamNext) featuredTeamNext.addEventListener("click", () => scrollByCards(1));
let rafId = 0;
const syncDots = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const cards = Array.from(featuredTeamTrack.children || []);
if (!cards.length) return renderFeaturedDots(0, 0);
const trackLeft = featuredTeamTrack.getBoundingClientRect().left;
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
cards.forEach((card, index) => {
const distance = Math.abs(card.getBoundingClientRect().left - trackLeft);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
});
renderFeaturedDots(cards.length, bestIndex);
});
};
featuredTeamTrack.addEventListener("scroll", syncDots, { passive: true });
window.addEventListener("resize", syncDots);
syncDots();
}
async function loadFeaturedStaff() {
if (!featuredTeamSection || !featuredTeamTrack) return;
try {
const response = await fetch("/api/public/featured-staff?limit=24");
const data = await parseJsonSafe(response);
const items = Array.isArray(data?.items) ? data.items : [];
if (!response.ok || items.length === 0) {
featuredTeamSection.hidden = true;
return;
}
featuredTeamTrack.innerHTML = "";
items.forEach((item) => {
const card = document.createElement("article");
card.className = "featured-card";
const avatar = document.createElement("img");
avatar.className = "featured-avatar";
avatar.src = String(item.avatar_url || "");
avatar.alt = String(item.name || "Сотрудник");
avatar.loading = "lazy";
card.appendChild(avatar);
const body = document.createElement("div");
body.className = "featured-card-body";
const top = document.createElement("div");
top.className = "featured-card-top";
const name = document.createElement("h3");
name.textContent = String(item.name || "Сотрудник");
top.appendChild(name);
if (item.pinned) {
const chip = document.createElement("span");
chip.className = "featured-chip";
chip.textContent = "Рекомендуем";
top.appendChild(chip);
}
body.appendChild(top);
const meta = document.createElement("p");
meta.className = "featured-meta";
meta.textContent = [item.role_label, item.primary_topic_name].filter(Boolean).join(" • ");
body.appendChild(meta);
const caption = document.createElement("p");
caption.className = "featured-caption";
caption.textContent = String(item.caption || "Практический опыт в сложных юридических делах и сопровождении споров.");
body.appendChild(caption);
card.appendChild(body);
featuredTeamTrack.appendChild(card);
});
featuredTeamSection.hidden = false;
initFeaturedCarouselControls();
} catch (_) {
featuredTeamSection.hidden = true;
}
}
accessSendOtpButton.addEventListener("click", async () => {
const phone = normalizeRuPhone(accessPhoneInput?.value);
if (accessPhoneInput && phone) accessPhoneInput.value = formatRuPhone(phone);
const email = normalizeEmail(accessEmailInput?.value);
const hpField = String(accessHpInput?.value || "").trim();
const channel = preferredChannel({ phone, email });
if (currentAuthMode() === "totp") {
setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error");
return;
}
if (channel === "EMAIL" && !email) {
setStatus(accessStatus, "Введите email.", "error");
return;
}
if (channel === "SMS" && !phone) {
setStatus(accessStatus, "Введите номер телефона.", "error");
return;
}
if (channel === "SMS" && !isValidRuPhone(phone)) {
setStatus(accessStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
return;
}
try {
setStatus(accessStatus, "Отправляем OTP-код...", null);
const response = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
client_phone: phone,
client_email: email,
hp_field: hpField,
channel,
}),
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
const effectiveChannel = String(data?.channel || channel || "SMS").toUpperCase();
lastAccessOtpChannel = effectiveChannel;
const debugCode = String(data?.delivery_response?.debug_code || data?.sms_response?.debug_code || "").trim();
if (debugCode) {
console.info("[OTP DEV] VIEW_REQUEST code (" + otpCodeDeliveryLabel(effectiveChannel) + "):", debugCode);
}
setStatus(accessStatus, "Код отправлен. Проверьте " + otpCodeDeliveryLabel(effectiveChannel) + ".", "ok");
} catch (error) {
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
}
});
accessForm.addEventListener("submit", async (event) => {
event.preventDefault();
const phone = normalizeRuPhone(accessPhoneInput?.value);
if (accessPhoneInput && phone) accessPhoneInput.value = formatRuPhone(phone);
const email = normalizeEmail(accessEmailInput?.value);
const code = String(accessCodeInput.value || "").trim();
const channel = preferredChannel({ phone, email });
if (channel === "EMAIL" && (!email || !code)) {
setStatus(accessStatus, "Введите email и OTP-код.", "error");
return;
}
if (channel === "SMS" && (!phone || !code)) {
setStatus(accessStatus, "Введите телефон и OTP-код.", "error");
return;
}
if (channel === "SMS" && !isValidRuPhone(phone)) {
setStatus(accessStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
return;
}
try {
setStatus(accessStatus, "Проверяем OTP...", null);
const response = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
client_phone: phone,
client_email: email,
channel: lastAccessOtpChannel || channel,
code,
}),
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "OTP не подтвержден"));
setStatus(accessStatus, "Доступ подтвержден. Переходим...", "ok");
window.location.href = "/client.html";
} catch (error) {
setStatus(accessStatus, error?.message || "Ошибка проверки OTP", "error");
}
});
requestForm.addEventListener("submit", async (event) => {
event.preventDefault();
setStatus(requestStatus, "Отправляем заявку...", null);
const payload = {
client_name: String(document.getElementById("name").value || "").trim(),
client_phone: normalizeRuPhone(requestPhoneInput?.value),
client_email: normalizeEmail(requestEmailInput?.value),
pdn_consent: Boolean(document.getElementById("pdn-consent")?.checked),
hp_field: String(requestHpInput?.value || "").trim(),
topic_code: String(document.getElementById("topic").value || "").trim(),
description: String(document.getElementById("description").value || "").trim(),
extra_fields: {},
};
const createChannel = preferredChannel({ phone: payload.client_phone, email: payload.client_email });
if (createChannel === "EMAIL" && !payload.client_email) {
setStatus(requestStatus, "Введите email для получения OTP.", "error");
return;
}
if (createChannel === "SMS" && !payload.client_phone) {
setStatus(requestStatus, "Введите телефон для получения OTP.", "error");
return;
}
if (!isValidRuPhone(payload.client_phone)) {
setStatus(requestStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
return;
}
if (requestPhoneInput && payload.client_phone) requestPhoneInput.value = formatRuPhone(payload.client_phone);
if (!payload.client_name || !payload.topic_code) {
setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
return;
}
if (!payload.pdn_consent) {
setStatus(requestStatus, "Необходимо согласие на обработку персональных данных.", "error");
return;
}
try {
setStatus(requestStatus, "Отправляем OTP-код...", null);
const otpSend = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone,
client_email: payload.client_email,
hp_field: payload.hp_field,
channel: createChannel,
}),
});
const otpSendData = await parseJsonSafe(otpSend);
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
const effectiveChannel = String(otpSendData?.channel || createChannel || "SMS").toUpperCase();
lastCreateOtpChannel = effectiveChannel;
const debugCode = String(otpSendData?.delivery_response?.debug_code || otpSendData?.sms_response?.debug_code || "").trim();
if (debugCode) {
console.info("[OTP DEV] CREATE_REQUEST code (" + otpCodeDeliveryLabel(effectiveChannel) + "):", debugCode);
}
const deliveryResponse = otpSendData?.delivery_response || otpSendData?.sms_response || {};
const provider = String(deliveryResponse?.provider || "").toLowerCase();
const isMocked = Boolean(deliveryResponse?.mocked) || provider === "mock_sms" || provider === "mock_email";
const code = await requestOtpCode(
isMocked
? "Введите OTP-код (dev-режим: смотрите backend console)."
: "Введите OTP-код из " + otpCodeDeliveryLabel(effectiveChannel) + "."
);
if (!code) throw new Error("Код OTP не введен");
setStatus(requestStatus, "Проверяем OTP...", null);
const otpVerify = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone,
client_email: payload.client_email,
channel: lastCreateOtpChannel || createChannel,
code: String(code).trim(),
}),
});
const otpVerifyData = await parseJsonSafe(otpVerify);
if (!otpVerify.ok) throw new Error(apiErrorDetail(otpVerifyData, "OTP не подтвержден"));
setStatus(requestStatus, "Создаем заявку...", null);
const response = await fetch("/api/public/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось создать заявку"));
setStatus(requestStatus, "Заявка принята. Номер: " + data.track_number, "ok");
requestForm.reset();
setTimeout(() => closeModal(requestModal), 1200);
} catch (error) {
setStatus(requestStatus, error?.message || "Не удалось отправить заявку. Повторите попытку позже.", "error");
}
});
loadAuthConfig();
bindRuPhoneMask(requestPhoneInput);
bindRuPhoneMask(accessPhoneInput);
loadTopics();
loadQuotes();
loadFeaturedStaff();
})();