Bundles feature WIP + fix request-access products cap (20 -> 200)

- app/main.py: raise products_raw[:20] cap to [:200] in /api/request-access
  and /api/request-more-access - was silently truncating access requests
  with more than 20 selected products
- accumulated bundles/product-page WIP (config, models, runtime, templates)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:58:09 +00:00
parent f5c53f01bf
commit 40de4d5a0f
12 changed files with 2407 additions and 457 deletions
+235 -215
View File
@@ -41,10 +41,6 @@
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
<link rel="manifest" href="/static/manifest.json" />
<style>
body { background: #070f1c; overflow: hidden; height: 100vh; }
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
</style>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
@@ -57,60 +53,251 @@
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<style>
:root{
--lg2-bg:#eef1f5; --lg2-panel:#ffffff; --lg2-raised:#ffffff;
--lg2-line:#d7dee6; --lg2-line-soft:#e3e8ee;
--lg2-fg:#16202c; --lg2-fg-dim:#5c6b7c; --lg2-fg-faint:#93a1b0;
--lg2-accent:#c96c14; --lg2-accent-dim:rgba(201,108,20,.10); --lg2-accent-ink:#2a1400;
--lg2-bad:#d3453f; --lg2-bad-dim:rgba(211,69,63,.08);
--lg2-info:#1f6fb2; --lg2-info-dim:rgba(31,111,178,.08);
--lg2-good:#1f9d63;
}
html{ background: var(--lg2-bg); }
body.login-page{
margin:0;min-height:100vh;background:var(--lg2-bg);color:var(--lg2-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;overflow-x:hidden;
}
.lg2-wrap{display:grid;grid-template-columns:1.15fr 420px;min-height:100vh}
@media (max-width:980px){ .lg2-wrap{grid-template-columns:minmax(0,1fr)} }
.lg2-pitch{
position:relative;min-width:0;padding:56px clamp(28px,5vw,72px) 48px;border-right:1px solid var(--lg2-line);
background-image:linear-gradient(var(--lg2-line-soft) 1px, transparent 1px),linear-gradient(90deg, var(--lg2-line-soft) 1px, transparent 1px);
background-size:34px 34px;display:flex;flex-direction:column;
}
@media (max-width:980px){ .lg2-pitch{border-right:none;border-bottom:1px solid var(--lg2-line)} }
.lg2-brand{display:flex;flex-wrap:wrap;align-items:center;gap:12px 16px;margin-bottom:40px}
.lg2-brand img{height:59px;display:block}
.lg2-brand-mark{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.14em;color:var(--lg2-fg-faint)}
@media (max-width:520px){
.lg2-brand{flex-direction:column;align-items:flex-start;gap:10px}
.lg2-brand img{height:40px}
}
.lg2-eyebrow{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--lg2-accent);margin:0 0 16px}
.lg2-pitch h1{font:800 clamp(30px,3.6vw,46px)/1.06 "Ubuntu",sans-serif;letter-spacing:-.01em;margin:0 0 18px;text-wrap:balance;max-width:16ch;color:var(--lg2-fg);overflow-wrap:break-word;hyphens:auto}
.lg2-pitch, .lg2-pitch *{ overflow-wrap: break-word; }
.lg2-pitch p.lead{font:400 15px/1.65 "Ubuntu",sans-serif;color:var(--lg2-fg-dim);max-width:52ch;margin:0 0 32px}
.lg2-feat-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--lg2-line);border:1px solid var(--lg2-line);border-radius:10px;overflow:hidden;margin-bottom:40px}
@media (max-width:720px){ .lg2-feat-row{grid-template-columns:repeat(2,minmax(0,1fr))} }
@media (max-width:420px){ .lg2-feat-row{grid-template-columns:minmax(0,1fr)} }
.lg2-feat{position:relative;min-width:0;background:var(--lg2-panel);padding:18px 16px 16px;border-top:2px solid var(--lg2-accent)}
.lg2-feat-idx{font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.08em;color:var(--lg2-fg-faint);display:block;margin-bottom:12px}
.lg2-feat-icon{width:26px;height:26px;margin-bottom:14px;color:var(--lg2-accent);display:block}
.lg2-feat-icon svg{width:100%;height:100%;display:block}
.lg2-feat-label{font:600 13px/1.35 "Ubuntu",sans-serif;color:var(--lg2-fg);margin-bottom:6px}
.lg2-feat-desc{font:400 11.5px/1.55 "Ubuntu",sans-serif;color:var(--lg2-fg-dim)}
.lg2-distrib-btn{
align-self:flex-start;margin:0 0 44px;padding:11px 22px;border:1px solid var(--lg2-line);border-radius:9px;
background:transparent;color:var(--lg2-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;letter-spacing:.02em;cursor:pointer;
}
.lg2-distrib-btn:hover{border-color:var(--lg2-accent);color:var(--lg2-fg)}
.lg2-wall-head{display:flex;align-items:baseline;gap:10px;margin-bottom:14px}
.lg2-wall-head h2{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--lg2-fg-dim);margin:0}
.lg2-wall-head::after{content:"";flex:1;height:1px;background:var(--lg2-line)}
.lg2-wall-count{font:600 11px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint)}
.lg2-logo-wall{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:10px;max-width:760px}
.lg2-wall-tile{aspect-ratio:1;background:var(--lg2-panel);border:1px solid var(--lg2-line);border-radius:9px;display:flex;align-items:center;justify-content:center;padding:8px}
a.lg2-wall-tile{text-decoration:none;cursor:pointer}
.lg2-wall-tile img{width:100%;height:100%;object-fit:contain;filter:grayscale(1) opacity(.75)}
.lg2-wall-tile:hover img{filter:none}
.lg2-wall-tile:hover{border-color:var(--lg2-accent)}
.lg2-pitch-footer{margin-top:auto;padding-top:36px;font:400 11px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint)}
.lg2-pitch-footer a{color:inherit;text-decoration:none}
.lg2-pitch-footer a:hover{color:var(--lg2-fg-dim)}
.lg2-auth{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:48px 40px;background:var(--lg2-bg);
position:sticky;top:0;height:100vh;box-sizing:border-box}
@media (max-width:980px){ .lg2-auth{position:static;height:auto} }
.lg2-auth-card{width:100%;max-width:320px;margin:0 auto}
.lg2-auth-title{font:700 22px/1.2 "Ubuntu",sans-serif;letter-spacing:-.01em;margin:0 0 4px;color:var(--lg2-fg)}
.lg2-auth-sub{font:400 12px/1.4 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint);letter-spacing:.02em;margin:0 0 18px;text-transform:uppercase}
.lg2-prompt{font:400 12.5px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-dim);margin:0 0 24px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lg2-prompt-user{color:var(--lg2-good)}
.lg2-prompt-path{color:var(--lg2-accent)}
.lg2-caret{display:inline-block;width:7px;height:13px;background:var(--lg2-accent);vertical-align:-2px;margin-left:3px;animation:lg2-blink 1.1s step-end infinite}
@keyframes lg2-blink{50%{opacity:0}}
@media (prefers-reduced-motion: reduce){ .lg2-caret{animation:none} }
.lg2-notice, .lg2-error{
border-radius:9px;padding:10px 13px;font:500 12.5px/1.5 "Ubuntu",sans-serif;margin-bottom:18px;
}
.lg2-notice{background:var(--lg2-info-dim);border:1px solid rgba(127,199,255,.35);color:var(--lg2-info)}
.lg2-error{background:var(--lg2-bad-dim);border:1px solid rgba(255,107,107,.35);color:var(--lg2-bad)}
.lg2-field{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
.lg2-field label{font:700 10.5px/1 "Ubuntu Mono",monospace;letter-spacing:.09em;text-transform:uppercase;color:var(--lg2-fg-faint)}
.lg2-field input{background:var(--lg2-panel);border:1px solid var(--lg2-line);border-radius:9px;padding:11px 13px;color:var(--lg2-fg);font:400 14px/1 "Ubuntu",sans-serif;outline:none;width:100%;box-sizing:border-box}
.lg2-field input::placeholder{color:var(--lg2-fg-faint)}
.lg2-field input:focus{border-color:var(--lg2-accent)}
.lg2-submit{width:100%;margin-top:6px;padding:12px 16px;border:none;border-radius:9px;cursor:pointer;background:var(--lg2-accent);color:var(--lg2-accent-ink);font:700 13.5px/1 "Ubuntu",sans-serif;letter-spacing:.02em}
.lg2-submit:hover{filter:brightness(1.06)}
.lg2-request-btn{width:100%;margin-top:12px;padding:10px 16px;border-radius:9px;border:1px solid var(--lg2-line);background:transparent;color:var(--lg2-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer}
.lg2-request-btn:hover{border-color:var(--lg2-accent);color:var(--lg2-fg)}
.lg2-auth-foot{margin-top:40px;text-align:center}
.lg2-auth-foot a{color:var(--lg2-fg-faint);font-size:11px;text-decoration:none}
.lg2-auth-foot a:hover{color:var(--lg2-fg-dim)}
/* Re-skin the shared access-modal component to match the new panel palette
(it's used by "Запросить доступ" here and on the dashboard alike, so we
override on top of style.css instead of editing it there). */
.access-modal-overlay{ background: rgba(6,9,14,.82) !important; }
.access-modal{
background: var(--lg2-panel) !important; border: 1px solid var(--lg2-line) !important;
box-shadow: 0 24px 80px rgba(0,0,0,.45) !important;
}
.access-modal-title{ color: var(--lg2-fg) !important; font-family:"Ubuntu",sans-serif; }
.access-modal-sub{ color: var(--lg2-fg-faint) !important; }
.access-field label{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-field .req{ color: var(--lg2-accent) !important; }
.access-field input[type=text],
.access-field input[type=email],
.access-field input[type=tel],
.access-textarea{
background: var(--lg2-raised) !important; border: 1px solid var(--lg2-line) !important;
color: var(--lg2-fg) !important; font-family:"Ubuntu",sans-serif;
}
.access-field input[type=text]::placeholder,
.access-field input[type=email]::placeholder,
.access-field input[type=tel]::placeholder,
.access-textarea::placeholder{ color: var(--lg2-fg-faint) !important; }
.access-field input[type=text]:focus,
.access-field input[type=email]:focus,
.access-field input[type=tel]:focus,
.access-textarea:focus{
border-color: var(--lg2-accent) !important; box-shadow: 0 0 0 3px var(--lg2-accent-dim) !important;
}
.access-products-wrap{ background: var(--lg2-raised) !important; border: 1px solid var(--lg2-line) !important; }
.access-products-loading{ color: var(--lg2-fg-faint) !important; }
.access-products-cat{ color: var(--lg2-fg-faint) !important; }
.access-product-item{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-product-item:hover span{ color: var(--lg2-fg) !important; }
.access-product-item input[type=checkbox]{ accent-color: var(--lg2-accent); }
.access-modal-error{
background: var(--lg2-bad-dim) !important; border: 1px solid rgba(255,107,107,.35) !important;
color: var(--lg2-bad) !important;
}
.access-btn-cancel{
background: transparent !important; border: 1px solid var(--lg2-line) !important; color: var(--lg2-fg-dim) !important;
font-family:"Ubuntu",sans-serif;
}
.access-btn-cancel:hover{ border-color: var(--lg2-fg-faint) !important; color: var(--lg2-fg) !important; background: transparent !important; }
.access-btn-submit{
background: var(--lg2-accent) !important; color: var(--lg2-accent-ink) !important; box-shadow: none !important;
font-family:"Ubuntu",sans-serif; font-weight: 700 !important;
}
.access-btn-submit:hover:not(:disabled){ opacity: 1 !important; filter: brightness(1.06); }
.access-consent-label span{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-consent-link{ color: var(--lg2-accent) !important; }
.am-success-title{ color: var(--lg2-fg) !important; }
.am-success-sub{ color: var(--lg2-fg-dim) !important; }
.am-success-icon{ color: var(--lg2-good) !important; }
@media (max-width:820px){
.lg2-pitch{padding:40px 22px 34px}
.lg2-auth{padding:36px 22px}
}
</style>
</head>
<body>
<div class="login-wrap">
<aside class="login-left">
<div class="login-left-glow login-left-glow-top"></div>
<div class="login-left-glow login-left-glow-bottom"></div>
<div class="login-left-inner">
<a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;"><img src="/static/logo.png?v=4" alt="MONT" class="login-corner-logo" /></a>
<h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
<p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
<ul class="login-features">
<li class="login-feature">
<span class="login-feature-icon">🖥</span>
<span>Доступ к рабочим столам ОС</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">🌐</span>
<span>Веб-интерфейсы сервисов</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">⚡</span>
<span>Доступ в один клик</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">🔒</span>
<span>Защищённый контур</span>
</li>
</ul>
<button type="button" class="login-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
<body class="login-page">
<div class="lg2-wrap">
<aside class="lg2-pitch">
<div class="lg2-brand">
<a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;">
<img src="/static/logo-color.png?v=1" alt="MONT" />
</a>
<span class="lg2-brand-mark">Инфраструктурный полигон</span>
</div>
<p class="lg2-eyebrow">Демо и пилоты российского ПО</p>
<h1>Полигон импортонезависимой инфраструктуры</h1>
<p class="lead">Платформа для демонстрации и пилотного тестирования российского ПО. Партнёры MONT
и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами
виртуализации, СРК и другими решениями — без установки и настройки.</p>
<div class="lg2-feat-row">
<div class="lg2-feat">
<span class="lg2-feat-idx">01</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg></span>
<div class="lg2-feat-label">Доступ к рабочим столам ОС</div>
<div class="lg2-feat-desc">Полный рабочий стол Astra, RED OS, Uncom и других ОС прямо в браузере, без RDP-клиента.</div>
</div>
<div class="lg2-feat">
<span class="lg2-feat-idx">02</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.7-3.8-9s1.3-6.4 3.8-9z"/></svg></span>
<div class="lg2-feat-label">Веб-интерфейсы сервисов</div>
<div class="lg2-feat-desc">Админ-панели и порталы продуктов открываются как обычная вкладка браузера.</div>
</div>
<div class="lg2-feat">
<span class="lg2-feat-idx">03</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg></span>
<div class="lg2-feat-label">Доступ в один клик</div>
<div class="lg2-feat-desc">Стенд поднимается автоматически — ничего разворачивать и настраивать не нужно.</div>
</div>
<div class="lg2-feat">
<span class="lg2-feat-idx">04</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="9" rx="1.5"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></span>
<div class="lg2-feat-label">Защищённый контур</div>
<div class="lg2-feat-desc">Изолированная сеть с резервированием и регулярными бэкапами всех стендов.</div>
</div>
</div>
<button type="button" class="lg2-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
{% set wall_services = public_services|rejectattr('slug', 'equalto', 'communigate-pro-user')|list %}
{% if wall_services %}
<div class="lg2-wall-head" id="products"><h2>Что на полигоне</h2><span class="lg2-wall-count">{{ wall_services|length }} продуктов</span></div>
<div class="lg2-logo-wall">
{% for service in wall_services %}
{% set wall_name = 'CommuniGate Pro' if service.slug == 'communigate-pro-admin' else service.name %}
<a class="lg2-wall-tile" href="/product/{{ service.slug }}" title="{{ wall_name }} — подробнее">
<img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="{{ wall_name }}" />
</a>
{% endfor %}
</div>
{% endif %}
<div class="lg2-pitch-footer">MONT PROVING GROUND</div>
</aside>
<main class="login-right">
<div class="login-right-inner">
<div class="login-form-title">Вход в систему</div>
<div class="login-form-subtitle">Инфраструктурный полигон</div>
{% if session_notice %}<div class="session-notice lp-session-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="auth-error lp-auth-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login" class="login-form">
<main class="lg2-auth">
<div class="lg2-auth-card">
<div class="lg2-auth-title">Вход в систему</div>
<div class="lg2-auth-sub">Инфраструктурный полигон</div>
<div class="lg2-prompt"><span class="lg2-prompt-user">mont@polygon</span><span class="lg2-prompt-path">:~/access</span>$ ./login.sh<span class="lg2-caret"></span></div>
{% if session_notice %}<div class="lg2-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="lg2-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="login-field">
<div class="lg2-field">
<label>Логин</label>
<input type="text" name="username" placeholder="Введите логин" required autocomplete="username" />
</div>
<div class="login-field">
<div class="lg2-field">
<label>Пароль</label>
<input type="password" name="password" placeholder="Введите пароль" required autocomplete="current-password" />
</div>
<button type="submit" class="login-submit">Войти</button>
<button type="submit" class="lg2-submit">Войти</button>
</form>
<button type="button" class="login-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button>
<button type="button" class="lg2-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button>
<div class="lg2-auth-foot"><a href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></div>
</div>
<footer class="login-footer">
<a href="mailto:ruslan@ipcom.su" class="login-footer-link">Made by Galyaviev</a>
</footer>
</main>
</div>
@@ -356,179 +543,12 @@
})();
</script>
<!-- Contact Ruslan Modal -->
<div id="contact-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Связаться с Русланом</div>
</div>
<div class="access-modal-body" id="cm-body">
<div class="access-field">
<label>Ваше имя <span class="req">*</span></label>
<input id="cm-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="cm-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Сообщение <span class="req">*</span></label>
<textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="cm-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer" id="cm-footer">
<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('contact-modal');
const btnCancel = document.getElementById('cm-cancel');
const btnSubmit = document.getElementById('cm-submit');
const errEl = document.getElementById('cm-error');
function resetContactForm() {
if (!document.getElementById('cm-name')) {
document.getElementById('cm-body').innerHTML = `
<div class="access-field"><label>Ваше имя <span class="req">*</span></label><input id="cm-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="cm-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Сообщение <span class="req">*</span></label><textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="cm-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>`;
document.getElementById('cm-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button><button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>`;
document.getElementById('cm-cancel').addEventListener('click', closeContact);
document.getElementById('cm-submit').addEventListener('click', submitContact);
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
} else {
['cm-name','cm-email','cm-phone','cm-text'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
var err=document.getElementById('cm-error'); if(err) err.style.display='none';
var btn=document.getElementById('cm-submit'); if(btn){btn.disabled=false;btn.textContent='Отправить';}
}
}
function openContact() {
resetContactForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeContact() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('#contact-modal .am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeContactModal = closeContact;
async function submitContact() {
const nameEl = document.getElementById('cm-name');
const emailEl = document.getElementById('cm-email');
const phoneEl = document.getElementById('cm-phone');
const textEl = document.getElementById('cm-text');
const submitBtn = document.getElementById('cm-submit');
const errorEl = document.getElementById('cm-error');
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const text = textEl ? textEl.value.trim() : '';
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('cm-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
{ el: textEl, check: () => !!text, msg: 'Введите сообщение' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, email, phone, text}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
document.getElementById('cm-body').innerHTML =
'<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Отправлено</div>' +
'<div class="am-success-sub">Постараюсь ответить в ближайшее время</div>' +
'</div>';
document.getElementById('cm-footer').innerHTML =
'<button type="button" class="access-btn-cancel" onclick="window._closeContactModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('cm-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('cm-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Отправить'; }
}
}
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
document.getElementById('btn-contact-ruslan').addEventListener('click', function(e) {
e.preventDefault();
openContact();
});
btnCancel.addEventListener('click', closeContact);
btnSubmit.addEventListener('click', submitContact);
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeContact(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.style.display !== 'none') closeContact(); });
})();
</script>
<!-- Cookie Banner -->
<div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:99999;background:rgba(7,15,28,0.97);border-top:1px solid rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:14px 24px;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<p style="margin:0;font-size:0.85rem;color:#c8d8ea;line-height:1.5;flex:1;min-width:200px;">
Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#5b9bd5;text-decoration:underline;">Политикой конфиденциальности</a>.
Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#ff9d3d;text-decoration:underline;">Политикой конфиденциальности</a>.
</p>
<button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#1a5db5,#2d8cf0);color:#fff;border:none;border-radius:8px;font-size:0.88rem;font-weight:600;cursor:pointer;white-space:nowrap;">Принять</button>
<button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#ffb15c,#e88420);color:#1a0f00;border:none;border-radius:8px;font-size:0.88rem;font-weight:700;cursor:pointer;white-space:nowrap;">Принять</button>
</div>
<script>
if (!localStorage.getItem('cookie_accepted')) {