Law/app/web/landing.html
2026-02-23 17:54:19 +03:00

1531 lines
50 KiB
HTML
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.

<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аудиторы корпоративной безопасности</title>
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Prata&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d1217;
--bg-soft: #121a22;
--surface: #171f29;
--surface-2: #1f2a37;
--text: #f4f7fb;
--muted: #a8b2c2;
--accent: #d4a968;
--accent-soft: rgba(212, 169, 104, 0.15);
--line: rgba(207, 217, 231, 0.18);
--ok: #49b68e;
--danger: #ff7b7b;
--radius: 18px;
--shadow: 0 30px 70px rgba(0, 0, 0, 0.32);
--maxw: 1180px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 12% 0%, #1a2430 0, var(--bg) 48%), var(--bg);
color: var(--text);
font-family: "Manrope", sans-serif;
scroll-behavior: smooth;
overflow-x: hidden;
}
body.modal-open { overflow: hidden; }
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(600px 320px at 90% 8%, rgba(212, 169, 104, 0.1), transparent 70%),
radial-gradient(600px 360px at 10% 76%, rgba(94, 147, 227, 0.1), transparent 72%);
z-index: -1;
}
.wrap {
width: min(var(--maxw), calc(100% - 1.5rem));
margin: 0 auto;
}
section { scroll-margin-top: 84px; }
.topbar {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(10px);
background: rgba(13, 18, 23, 0.78);
border-bottom: 1px solid var(--line);
}
.topbar-inner {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
font-size: 0.84rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
max-width: 390px;
color: #eef4ff;
}
.nav {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
color: #d6deea;
font-size: 0.93rem;
font-weight: 600;
opacity: 0.92;
}
.btn {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.82rem 1.25rem;
font-family: inherit;
font-size: 0.93rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary {
background: linear-gradient(120deg, #d8b27b, #c6914a);
color: #17212d;
box-shadow: 0 16px 28px rgba(198, 145, 74, 0.3);
}
.btn-ghost {
border-color: var(--line);
color: #dde6f2;
background: rgba(255, 255, 255, 0.04);
}
.hero {
padding: 5.2rem 0 3rem;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1.1rem;
align-items: stretch;
}
.hero h1 {
margin: 0;
font-family: "Prata", serif;
font-size: clamp(2.05rem, 5.6vw, 4.2rem);
line-height: 1.08;
max-width: 13ch;
letter-spacing: 0.01em;
}
.hero p {
margin: 1.1rem 0 0;
color: var(--muted);
line-height: 1.66;
font-size: 1.05rem;
max-width: 66ch;
}
.hero-actions {
margin-top: 1.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(160deg, rgba(35, 48, 63, 0.92), rgba(21, 29, 39, 0.95));
box-shadow: var(--shadow);
padding: 1.3rem;
position: relative;
overflow: hidden;
}
.panel::before {
content: "";
position: absolute;
right: -40px;
top: -40px;
width: 140px;
height: 140px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212, 169, 104, 0.2), transparent 70%);
}
.panel small {
display: block;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
color: #90a2b7;
font-weight: 800;
margin-bottom: 0.6rem;
}
.panel strong {
display: block;
font-size: 1.06rem;
line-height: 1.45;
margin-bottom: 0.9rem;
}
.stats {
margin-top: 0.95rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
.stat {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.7rem;
}
.stat b {
display: block;
font-size: 1.15rem;
color: #f7dbb1;
margin-bottom: 0.15rem;
}
.stat span {
font-size: 0.78rem;
color: #a7b3c5;
line-height: 1.35;
}
section { padding: 1.3rem 0 2.2rem; }
.section-head {
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
}
h2 {
margin: 0;
font-family: "Prata", serif;
font-size: clamp(1.65rem, 4vw, 2.7rem);
letter-spacing: 0.01em;
}
.subtitle {
margin: 0.65rem 0 0;
color: var(--muted);
line-height: 1.6;
max-width: 70ch;
}
.grid { display: grid; gap: 0.9rem; }
.practices { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(25, 34, 45, 0.88), rgba(19, 26, 34, 0.95));
padding: 1.05rem;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(12px);
animation: rise 0.6s ease forwards;
}
.card:nth-child(2) { animation-delay: 0.06s; }
.card:nth-child(3) { animation-delay: 0.12s; }
.card:nth-child(4) { animation-delay: 0.18s; }
.card:nth-child(5) { animation-delay: 0.24s; }
.card:nth-child(6) { animation-delay: 0.3s; }
.card h3 {
margin: 0 0 0.52rem;
font-size: 1.03rem;
color: #f1f5fb;
}
.card p {
margin: 0;
color: #aab5c4;
line-height: 1.57;
font-size: 0.95rem;
}
.approach {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 0.9rem;
}
.timeline {
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
background: linear-gradient(160deg, rgba(22, 30, 40, 0.88), rgba(16, 23, 30, 0.95));
}
.timeline .step {
position: relative;
padding-left: 2.2rem;
margin-bottom: 1rem;
}
.timeline .step:last-child { margin-bottom: 0; }
.timeline .step::before {
content: attr(data-step);
position: absolute;
left: 0;
top: 0;
width: 1.55rem;
height: 1.55rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.74rem;
font-weight: 800;
color: #1b2634;
background: linear-gradient(130deg, #e3c08f, #c5914b);
}
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
.quote {
border: 1px solid #4b5b71;
border-radius: 16px;
background: linear-gradient(160deg, #1e2b3c, #1a2432);
padding: 1rem;
}
.quote p {
margin: 0;
min-height: 5.3rem;
line-height: 1.6;
color: #dbe6f5;
}
.quote-meta {
margin-top: 0.7rem;
color: #98adc7;
font-size: 0.86rem;
}
.expert {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(26, 36, 48, 0.92), rgba(20, 27, 36, 0.95));
padding: 1.1rem;
}
.expert strong {
display: block;
margin-bottom: 0.5rem;
font-size: 1.08rem;
}
.expert p {
margin: 0;
color: var(--muted);
line-height: 1.62;
}
.tags {
margin-top: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.38rem 0.62rem;
border-radius: 999px;
border: 1px solid rgba(212, 169, 104, 0.3);
background: var(--accent-soft);
color: #f6d7a8;
font-size: 0.8rem;
font-weight: 700;
}
.cta-band {
margin: 1.4rem 0 2.4rem;
border: 1px solid rgba(212, 169, 104, 0.35);
border-radius: 18px;
background: linear-gradient(120deg, rgba(212, 169, 104, 0.14), rgba(66, 99, 145, 0.18));
padding: 1.15rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.cta-band p {
margin: 0;
color: #d8e2f2;
line-height: 1.52;
max-width: 60ch;
}
footer {
border-top: 1px solid var(--line);
margin-top: 1.1rem;
padding: 1.8rem 0;
color: #94a6bc;
text-align: center;
font-size: 0.9rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(7, 10, 14, 0.72);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 40;
}
.modal-backdrop.open { display: flex; }
.modal {
width: min(620px, 100%);
max-height: 92vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(160deg, #18222e, #121a23);
box-shadow: var(--shadow);
padding: 1.15rem;
}
.modal-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.9rem;
}
.modal-head h3 {
margin: 0;
font-size: 1.28rem;
font-family: "Prata", serif;
line-height: 1.2;
}
.modal-head p {
margin: 0.5rem 0 0;
color: var(--muted);
line-height: 1.54;
font-size: 0.93rem;
}
.close {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: #dce5f2;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 1.1rem;
cursor: pointer;
}
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.34rem;
}
.field.full { grid-column: 1 / -1; }
label {
font-size: 0.76rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9fb0c6;
font-weight: 700;
}
input, textarea {
width: 100%;
border-radius: 12px;
border: 1px solid #3b4b5f;
background: rgba(255, 255, 255, 0.03);
color: #ecf2fb;
font: inherit;
font-size: 16px;
padding: 0.72rem 0.8rem;
}
textarea {
min-height: 108px;
resize: vertical;
}
.form-foot {
margin-top: 0.9rem;
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
}
.status {
margin: 0;
color: #9bafc8;
font-size: 0.9rem;
min-height: 1.2rem;
}
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
.cabinet-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
.cabinet-card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
padding: 1rem;
}
.cabinet-card h3 {
margin: 0 0 0.65rem;
font-size: 1.03rem;
}
.cabinet-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.7rem;
}
.meta-row {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.meta-row small {
display: block;
color: #9fb0c6;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.2rem;
}
.meta-row b {
display: block;
color: #eaf2ff;
font-size: 0.9rem;
font-weight: 700;
line-height: 1.4;
}
.simple-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
max-height: 280px;
overflow: auto;
}
.simple-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.simple-item p {
margin: 0.24rem 0 0;
color: #d8e3f3;
line-height: 1.5;
font-size: 0.92rem;
overflow-wrap: anywhere;
}
.simple-item time {
color: #9eb1ca;
font-size: 0.78rem;
}
.chat-form {
margin-top: 0.7rem;
display: grid;
gap: 0.55rem;
}
.chat-form textarea {
min-height: 84px;
}
.file-row {
margin-top: 0.7rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.file-row input[type="file"] {
max-width: 100%;
}
.brand,
.meta-row b {
overflow-wrap: anywhere;
}
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 980px) {
.hero,
.approach,
.practices {
grid-template-columns: 1fr;
}
}
@media (max-width: 740px) {
.topbar-inner {
flex-direction: column;
align-items: flex-start;
padding: 0.72rem 0;
}
.nav {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.nav a,
.nav .btn {
width: 100%;
text-align: center;
}
.hero {
padding-top: 2.7rem;
}
.stats {
grid-template-columns: 1fr;
}
.form {
grid-template-columns: 1fr;
}
.cabinet-layout {
grid-template-columns: 1fr;
}
.cabinet-meta {
grid-template-columns: 1fr;
}
.hero-actions .btn {
width: 100%;
}
.simple-list {
max-height: 220px;
}
}
@media (max-width: 520px) {
.wrap {
width: calc(100% - 1rem);
}
.topbar {
position: static;
}
section {
scroll-margin-top: 0;
}
.brand {
font-size: 0.78rem;
max-width: none;
}
.nav {
grid-template-columns: 1fr;
}
.hero {
padding-top: 1.4rem;
}
.panel,
.card,
.expert,
.cabinet-card {
padding: 0.85rem;
}
.file-row {
flex-direction: column;
align-items: stretch;
}
.file-row .btn {
width: 100%;
}
.modal-backdrop {
padding: 0;
}
.modal {
width: 100%;
max-height: 100vh;
min-height: 100vh;
border-radius: 0;
border: none;
padding: 0.95rem;
}
.modal-head {
position: sticky;
top: 0;
z-index: 2;
background: linear-gradient(160deg, #18222e, #121a23);
padding-bottom: 0.5rem;
margin-bottom: 0.7rem;
}
.close {
width: 38px;
height: 38px;
}
.form-foot .btn {
width: 100%;
}
}
</style>
</head>
<body>
<header class="topbar">
<div class="wrap topbar-inner">
<div class="brand">Аудиторы корпоративной безопасности</div>
<nav class="nav">
<a href="#practices">Компетенции</a>
<a href="#approach">Подход</a>
<a href="#expert">Эксперт</a>
<a href="#cabinet">Кабинет клиента</a>
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
</nav>
</div>
</header>
<main class="wrap">
<section class="hero">
<div>
<h1>Решаем сложные юридические задачи в интересах вашего бизнеса.</h1>
<p>
Консалтинговая компания «Аудиторы корпоративной безопасности» освобождает ваше время для развития компании.
Мы разбираем любой бизнес-процесс и предлагаем решение, выгодное клиенту в текущем и стратегическом горизонте.
</p>
<div class="hero-actions">
<button class="btn btn-primary" type="button" data-open-modal>Записаться на консультацию</button>
<a class="btn btn-ghost" href="#practices">Смотреть практики</a>
</div>
</div>
<aside class="panel">
<small>Первая консультация</small>
<strong>Анализ вашей ситуации проводит онлайн лично директор компании.</strong>
<p class="subtitle">После отправки заявки вы получите предложение по дате и времени онлайн-встречи.</p>
<div class="stats">
<div class="stat">
<b>28 лет</b>
<span>практики в профессии</span>
</div>
<div class="stat">
<b>10+ лет</b>
<span>стаж экспертов направлений</span>
</div>
<div class="stat">
<b>29 млрд ₽</b>
<span>объем восстановленных прав</span>
</div>
</div>
</aside>
</section>
<section id="practices">
<div class="section-head">
<div>
<h2>Ключевые практики</h2>
<p class="subtitle">Работаем в конфигурациях, где задача находится на стыке права, финансов, кадров и корпоративной безопасности.</p>
</div>
</div>
<div class="grid practices">
<article class="card">
<h3>Судебное сопровождение и арбитраж</h3>
<p>Налоговые и бюджетные споры, защита собственности и корпоративных прав, оспаривание сделок, сложные каскадные процессы.</p>
</article>
<article class="card">
<h3>Банкротство и антикризис</h3>
<p>Представление интересов должника, кредитора и иных участников в делах о банкротстве юридических и физических лиц.</p>
</article>
<article class="card">
<h3>Сделки и защита активов</h3>
<p>Сопровождение покупки и продажи активов, снижение правовых рисков, выстраивание безопасной структуры сделки.</p>
</article>
<article class="card">
<h3>Кадры и корпоративная устойчивость</h3>
<p>Подбор и адаптация персонала, мотивация, командная динамика, выявление конфликтов интересов и конкурентных рисков.</p>
</article>
<article class="card">
<h3>Финансовая и экспертная аналитика</h3>
<p>Оценка активов, экспертиза управленческих решений, формирование аргументированной позиции для переговоров и суда.</p>
</article>
<article class="card">
<h3>PR и GR сопровождение</h3>
<p>Поддержка чувствительных кейсов в публичном и регуляторном контуре с учетом репутационных и правовых факторов.</p>
</article>
</div>
</section>
<section id="approach">
<div class="section-head">
<div>
<h2>Как мы работаем</h2>
<p class="subtitle">К нам обращаются, когда ситуация сложная, решение нужно быстро, а цена ошибки для бизнеса высока.</p>
</div>
</div>
<div class="approach">
<article class="timeline">
<div class="step" data-step="01">
<h3>Диагностика ситуации</h3>
<p>Декомпозируем кейс на правовые, финансовые и управленческие блоки и определяем критические риски.</p>
</div>
<div class="step" data-step="02">
<h3>Стратегия защиты</h3>
<p>Формируем архитектуру действий: досудебное урегулирование, переговоры, процессуальная и судебная траектория.</p>
</div>
<div class="step" data-step="03">
<h3>Реализация и контроль</h3>
<p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p>
</div>
</article>
<article class="quote">
<small>Публичные цитаты</small>
<p id="quote-text">Загрузка данных...</p>
<div class="quote-meta" id="quote-meta"></div>
</article>
</div>
</section>
<section id="expert">
<div class="section-head">
<div>
<h2>Экспертный контур</h2>
<p class="subtitle">Генеральный директор ООО «Аудиторы корпоративной безопасности» — кандидат юридических наук, эксперт в сфере M&amp;A, медиатор и третейский судья.</p>
</div>
</div>
<article class="expert">
<strong>Направления деятельности: юридический консалтинг и судебное сопровождение</strong>
<p>
Защита права собственности и корпоративных прав, налоговые и бюджетные споры, сложные переговорные процессы,
оценка бизнес-рисков и активов, медиация и досудебное урегулирование конфликтов.
Преподавательская практика: ВШЭ, РАНХиГС, ЯрГУ им. Демидова и профильные программы МВА.
</p>
<div class="tags">
<span class="tag">Слияния и поглощения</span>
<span class="tag">Судебная стратегия</span>
<span class="tag">Корпоративная безопасность</span>
<span class="tag">Медиация</span>
</div>
</article>
</section>
<section id="cabinet">
<div class="section-head">
<div>
<h2>Кабинет клиента</h2>
<p class="subtitle">Введите номер заявки, подтвердите доступ по OTP и отслеживайте статус, переписку и файлы в одном окне.</p>
</div>
</div>
<div class="cabinet-layout">
<article class="cabinet-card">
<h3>Доступ по номеру заявки</h3>
<div class="field">
<label for="cabinet-track">Номер заявки</label>
<input id="cabinet-track" type="text" placeholder="TRK-XXXXXXXXXX">
</div>
<div class="form-foot">
<button class="btn btn-primary" id="cabinet-open" type="button">Открыть кабинет</button>
<p class="status" id="cabinet-status"></p>
</div>
<div id="cabinet-summary" hidden>
<div class="cabinet-meta">
<div class="meta-row">
<small>Статус</small>
<b id="cabinet-request-status">-</b>
</div>
<div class="meta-row">
<small>Тема</small>
<b id="cabinet-request-topic">-</b>
</div>
<div class="meta-row">
<small>Создана</small>
<b id="cabinet-request-created">-</b>
</div>
<div class="meta-row">
<small>Обновлена</small>
<b id="cabinet-request-updated">-</b>
</div>
</div>
</div>
</article>
<article class="cabinet-card">
<h3>Чат с юристом</h3>
<ul class="simple-list" id="cabinet-messages"></ul>
<form class="chat-form" id="cabinet-chat-form">
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
</form>
</article>
<article class="cabinet-card">
<h3>Файлы по заявке</h3>
<ul class="simple-list" id="cabinet-files"></ul>
<div class="file-row">
<input id="cabinet-file-input" type="file" disabled>
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
</div>
</article>
<article class="cabinet-card">
<h3>Счета и оплата</h3>
<ul class="simple-list" id="cabinet-invoices"></ul>
</article>
<article class="cabinet-card">
<h3>История изменений</h3>
<ul class="simple-list" id="cabinet-timeline"></ul>
</article>
</div>
</section>
<section class="cta-band">
<p>
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
Это поможет быстрее подготовиться к консультации и учесть контекст вашей ситуации.
</p>
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
</section>
</main>
<footer>
ООО «Аудиторы корпоративной безопасности» • Юридический консалтинг и судебное сопровождение
</footer>
<div class="modal-backdrop" id="request-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-head">
<div>
<h3 id="modal-title">Создание заявки</h3>
<p>Опишите задачу, и мы предложим дату и время первой онлайн-консультации.</p>
</div>
<button class="close" type="button" data-close-modal aria-label="Закрыть">×</button>
</div>
<form id="request-form" class="form">
<div class="field">
<label for="name">Имя</label>
<input id="name" name="name" type="text" required placeholder="Иван Иванов">
</div>
<div class="field">
<label for="phone">Телефон</label>
<input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00">
</div>
<div class="field full">
<label for="description">Описание задачи</label>
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
</div>
<div class="field full">
<label for="referral">Кто вас порекомендовал</label>
<input id="referral" name="referral" type="text" placeholder="Имя рекомендателя">
</div>
<div class="form-foot field full">
<button class="btn btn-primary" type="submit">Отправить заявку</button>
<p class="status" id="form-status"></p>
</div>
</form>
</div>
</div>
<script>
(function () {
const modal = document.getElementById("request-modal");
const openButtons = document.querySelectorAll("[data-open-modal]");
const closeButtons = document.querySelectorAll("[data-close-modal]");
const form = document.getElementById("request-form");
const status = document.getElementById("form-status");
const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta");
const cabinetTrackInput = document.getElementById("cabinet-track");
const cabinetOpenButton = document.getElementById("cabinet-open");
const cabinetStatus = document.getElementById("cabinet-status");
const cabinetSummary = document.getElementById("cabinet-summary");
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
const cabinetMessages = document.getElementById("cabinet-messages");
const cabinetFiles = document.getElementById("cabinet-files");
const cabinetInvoices = document.getElementById("cabinet-invoices");
const cabinetTimeline = document.getElementById("cabinet-timeline");
const cabinetChatForm = document.getElementById("cabinet-chat-form");
const cabinetChatBody = document.getElementById("cabinet-chat-body");
const cabinetChatSend = document.getElementById("cabinet-chat-send");
const cabinetFileInput = document.getElementById("cabinet-file-input");
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
let activeTrack = "";
let activeRequestId = "";
function openModal() {
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
}
function closeModal() {
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
}
openButtons.forEach((button) => button.addEventListener("click", openModal));
closeButtons.forEach((button) => button.addEventListener("click", closeModal));
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("open")) closeModal();
});
function formatDate(value) {
if (!value) return "-";
try {
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return value;
return dt.toLocaleString("ru-RU");
} catch (_) {
return value;
}
}
function setStatus(el, message, kind) {
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 setCabinetEnabled(enabled) {
cabinetChatBody.disabled = !enabled;
cabinetChatSend.disabled = !enabled;
cabinetFileInput.disabled = !enabled;
cabinetFileUpload.disabled = !enabled;
}
function clearList(node, emptyMessage) {
node.innerHTML = "";
const li = document.createElement("li");
li.className = "simple-item";
const p = document.createElement("p");
p.textContent = emptyMessage;
li.appendChild(p);
node.appendChild(li);
}
function renderMessages(items) {
cabinetMessages.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetMessages, "Сообщений пока нет.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const author = item.author_name || item.author_type || "Участник";
p.textContent = author + ": " + (item.body || "");
li.appendChild(p);
cabinetMessages.appendChild(li);
});
}
function renderFiles(items) {
cabinetFiles.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetFiles, "Файлы пока не загружены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetFiles.appendChild(li);
});
}
function renderInvoices(items) {
cabinetInvoices.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetInvoices, "Счета пока не выставлены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = "Сформирован: " + formatDate(item.issued_at);
li.appendChild(time);
const p = document.createElement("p");
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
p.textContent =
(item.invoice_number || "Счет") +
" • " +
(item.status_label || item.status || "-") +
" • " +
amount +
" " +
(item.currency || "RUB");
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать PDF";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetInvoices.appendChild(li);
});
}
function renderTimeline(items) {
cabinetTimeline.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetTimeline, "История пока пуста.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
if (item.type === "status_change") {
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
} else if (item.type === "message") {
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
} else if (item.type === "attachment") {
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
} else {
p.textContent = "Событие";
}
li.appendChild(p);
cabinetTimeline.appendChild(li);
});
}
async function loadQuotes() {
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];
quoteText.textContent = quote.text;
quoteMeta.textContent = [quote.author, quote.source].filter(Boolean).join(" • ");
index += 1;
};
render();
if (items.length > 1) setInterval(render, 5500);
} catch (error) {
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
quoteMeta.textContent = "Команда компании";
}
}
async function fetchRequestByTrack(trackNumber) {
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
const data = await parseJsonSafe(response);
return { response, data };
}
async function ensureViewAccess(trackNumber) {
let { response, data } = await fetchRequestByTrack(trackNumber);
if (response.ok) return data;
if (response.status !== 401 && response.status !== 403) {
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
}
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
const sendResponse = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber
})
});
const sendData = await parseJsonSafe(sendResponse);
if (!sendResponse.ok) {
throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP"));
}
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) {
throw new Error("Код OTP не введен");
}
setStatus(cabinetStatus, "Проверяем OTP...", null);
const verifyResponse = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber,
code: String(code).trim()
})
});
const verifyData = await parseJsonSafe(verifyResponse);
if (!verifyResponse.ok) {
throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден"));
}
({ response, data } = await fetchRequestByTrack(trackNumber));
if (!response.ok) {
throw new Error(apiErrorDetail(data, "Нет доступа к заявке"));
}
return data;
}
async function refreshCabinetData() {
if (!activeTrack) return;
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
]);
const messagesData = await parseJsonSafe(messagesRes);
const filesData = await parseJsonSafe(filesRes);
const invoicesData = await parseJsonSafe(invoicesRes);
const timelineData = await parseJsonSafe(timelineRes);
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
renderMessages(messagesData);
renderFiles(filesData);
renderInvoices(invoicesData);
renderTimeline(timelineData);
}
async function openCabinetByTrack() {
const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase();
if (!trackNumber) {
setStatus(cabinetStatus, "Введите номер заявки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Открываем кабинет...", null);
const requestData = await ensureViewAccess(trackNumber);
activeTrack = trackNumber;
activeRequestId = requestData.id;
cabinetRequestStatus.textContent = requestData.status_code || "-";
cabinetRequestTopic.textContent = requestData.topic_code || "Не указана";
cabinetRequestCreated.textContent = formatDate(requestData.created_at);
cabinetRequestUpdated.textContent = formatDate(requestData.updated_at);
cabinetSummary.hidden = false;
setCabinetEnabled(true);
await refreshCabinetData();
setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error");
}
}
cabinetOpenButton.addEventListener("click", () => {
openCabinetByTrack();
});
cabinetChatForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeTrack) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const body = String(cabinetChatBody.value || "").trim();
if (!body) return;
try {
setStatus(cabinetStatus, "Отправляем сообщение...", null);
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
cabinetChatBody.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
}
});
cabinetFileUpload.addEventListener("click", async () => {
if (!activeTrack || !activeRequestId) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const file = cabinetFileInput.files && cabinetFileInput.files[0];
if (!file) {
setStatus(cabinetStatus, "Выберите файл для загрузки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null);
const initResponse = await fetch("/api/public/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
cabinetFileInput.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Файл загружен.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error");
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
setStatus(status, "Отправляем заявку...", null);
const payload = {
client_name: document.getElementById("name").value.trim(),
client_phone: document.getElementById("phone").value.trim(),
topic_code: "consulting",
description: document.getElementById("description").value.trim(),
extra_fields: {
referral_name: document.getElementById("referral").value.trim()
}
};
try {
setStatus(status, "Отправляем 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
})
});
if (!otpSend.ok) throw new Error("otp send failed");
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) throw new Error("otp code required");
setStatus(status, "Проверяем 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,
code: String(code).trim()
})
});
if (!otpVerify.ok) throw new Error("otp verify failed");
setStatus(status, "Создаем заявку...", null);
const response = await fetch("/api/public/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("create request failed");
const data = await response.json();
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
cabinetTrackInput.value = data.track_number;
form.reset();
setTimeout(closeModal, 1200);
} catch (error) {
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
}
});
loadQuotes();
setCabinetEnabled(false);
clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetInvoices, "Счета пока не выставлены.");
clearList(cabinetTimeline, "История пока пуста.");
})();
</script>
</body>
</html>