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
+600
View File
@@ -0,0 +1,600 @@
{% set _scheme = request.headers.get('x-forwarded-proto', request.url.scheme) %}
{% set base_url = _scheme + '://' + request.url.netloc + '/' %}
{% set cat_names = categories|map(attribute='name')|list %}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ service.name }} — тестовый доступ | Инфраструктурный полигон MONT</title>
<meta name="description" content="{{ meta_description }}" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="{{ canonical_url }}" />
<meta property="og:type" content="product" />
<meta property="og:url" content="{{ canonical_url }}" />
<meta property="og:title" content="{{ service.name }} — Инфраструктурный полигон MONT" />
<meta property="og:description" content="{{ meta_description }}" />
<meta property="og:image" content="{{ base_url[:-1] }}{{ service.icon_path or '/static/service-placeholder.svg' }}" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="Полигон MONT" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ service.name }}" />
<meta name="twitter:description" content="{{ meta_description }}" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": {{ service.name|tojson }},
"url": {{ canonical_url|tojson }},
"description": {{ meta_description|tojson }},
"applicationCategory": {{ (cat_names[0] if cat_names else "BusinessApplication")|tojson }},
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "RUB",
"description": "Бесплатный тестовый доступ на инфраструктурном полигоне MONT"
},
"provider": {
"@type": "Organization",
"name": "MONT",
"url": "https://www.mont.ru/"
}
}
</script>
<link rel="stylesheet" href="/static/style.css?v=2" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon_32.png" />
<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" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</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:#0a0e14; --lg2-panel:#12181f; --lg2-raised:#1b232d;
--lg2-line:#232b36; --lg2-line-soft:#1a212a;
--lg2-fg:#e7ecf2; --lg2-fg-dim:#8391a3; --lg2-fg-faint:#56626f;
--lg2-accent:#ff9d3d; --lg2-accent-dim:rgba(255,157,61,.14); --lg2-accent-ink:#180d02;
--lg2-bad:#ff6b6b; --lg2-bad-dim:rgba(255,107,107,.12);
--lg2-info:#7fc7ff; --lg2-info-dim:rgba(127,199,255,.10);
--lg2-good:#5fd68a;
}
:root[data-theme="light"]{
--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;
}
@media (prefers-color-scheme:light){
:root:not([data-theme="dark"]){
--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;
}
}
*{ box-sizing:border-box; }
html{ background:var(--lg2-bg); }
body.product-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;
}
a{ color:inherit; }
.pg-header{
display:flex; align-items:center; justify-content:space-between; gap:16px;
padding:18px clamp(20px,4vw,40px); border-bottom:1px solid var(--lg2-line); background:var(--lg2-panel);
flex-wrap:wrap;
}
.pg-brand{ display:flex; align-items:center; gap:10px; text-decoration:none; }
.pg-brand img{ height:28px; display:block; }
.pg-brand span{ font:700 12px/1 "Ubuntu Mono",monospace; color:var(--lg2-fg-dim); letter-spacing:.06em; text-transform:uppercase; }
.pg-header-actions{ display:flex; gap:10px; }
.pg-btn{
display:inline-flex; align-items:center; gap:8px; border-radius:8px; text-decoration:none; cursor:pointer;
font:600 13px/1 "Ubuntu",sans-serif; padding:10px 18px; border:1px solid var(--lg2-line); white-space:nowrap;
}
.pg-btn-ghost{ background:transparent; color:var(--lg2-fg-dim); }
.pg-btn-ghost:hover{ border-color:var(--lg2-fg-faint); color:var(--lg2-fg); }
.pg-btn-accent{ background:var(--lg2-accent); color:var(--lg2-accent-ink); border-color:var(--lg2-accent); font-weight:700; }
.pg-btn-accent:hover{ filter:brightness(1.06); }
.pg-crumbs{
max-width:1180px; margin:22px auto 0; padding:0 clamp(20px,4vw,40px);
font:600 11px/1 "Ubuntu Mono",monospace; letter-spacing:.08em; text-transform:uppercase;
color:var(--lg2-fg-faint); display:flex; gap:8px; align-items:center; flex-wrap:wrap;
}
.pg-crumbs a{ text-decoration:none; color:var(--lg2-fg-faint); }
.pg-crumbs a:hover{ color:var(--lg2-accent); }
.pg-crumbs .sep{ opacity:.5; }
.pg-crumbs .current{ color:var(--lg2-fg-dim); }
.pg-hero{
max-width:1180px; margin:20px auto 0; padding:0 clamp(20px,4vw,40px);
display:grid; grid-template-columns:112px minmax(0,1fr); gap:26px; align-items:start;
}
@media (max-width:640px){ .pg-hero{ grid-template-columns:1fr; } }
.pg-plate{
width:112px; height:112px; border-radius:18px; background:var(--lg2-raised);
border:1px solid var(--lg2-line); display:flex; align-items:center; justify-content:center; flex:none;
}
.pg-plate img{ width:74px; height:74px; object-fit:contain; }
.pg-eyebrow{
font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.1em; text-transform:uppercase;
color:var(--lg2-accent); margin:0 0 10px; display:flex; align-items:center; gap:8px; flex-wrap:wrap;
}
.pg-eyebrow::before{ content:""; width:6px; height:6px; border-radius:50%; background:var(--lg2-good); box-shadow:0 0 0 3px var(--lg2-accent-dim); flex:none; }
.pg-hero h1{ font:800 clamp(24px,3.4vw,36px)/1.15 "Ubuntu",sans-serif; margin:0 0 12px; text-wrap:balance; overflow-wrap:break-word; }
.pg-lede{ font:400 16px/1.6 "Ubuntu",sans-serif; color:var(--lg2-fg-dim); max-width:64ch; margin:0 0 20px; }
.pg-actions{ display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
.pg-note{ font:400 12.5px/1.4 "Ubuntu",sans-serif; color:var(--lg2-fg-faint); }
.pg-prompt{
margin-top:22px; background:var(--lg2-panel); border:1px solid var(--lg2-line); border-radius:9px;
padding:12px 16px; font:400 13px/1.5 "Ubuntu Mono",monospace; color:var(--lg2-fg-dim); max-width:560px;
overflow-wrap:break-word;
}
.pg-prompt .p1{ color:var(--lg2-accent); }
.pg-prompt .p2{ color:var(--lg2-fg-faint); }
.pg-cursor{ display:inline-block; width:7px; height:14px; background:var(--lg2-accent); margin-left:2px; vertical-align:-2px; animation:pg-blink 1.1s step-end infinite; }
@keyframes pg-blink{ 50%{ opacity:0; } }
@media (prefers-reduced-motion: reduce){ .pg-cursor{ animation:none; } }
.pg-layout{
max-width:1180px; margin:44px auto 0; padding:0 clamp(20px,4vw,40px) 60px;
display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:28px;
}
@media (max-width:900px){ .pg-layout{ grid-template-columns:minmax(0,1fr); } }
.pg-card{
background:var(--lg2-panel); border:1px solid var(--lg2-line); border-radius:12px;
padding:26px 28px; margin-bottom:20px;
}
.pg-card > h2:first-child{ margin-top:0; }
.pg-desc h2{
font:700 13px/1 "Ubuntu Mono",monospace; letter-spacing:.08em; text-transform:uppercase;
color:var(--lg2-fg-faint); margin:28px 0 14px; padding-top:20px; border-top:1px solid var(--lg2-line-soft);
}
.pg-desc h2:first-child{ margin-top:0; padding-top:0; border-top:none; }
.pg-desc p{ margin:0 0 14px; color:var(--lg2-fg-dim); font-size:15px; line-height:1.65; }
.pg-desc p:last-child{ margin-bottom:0; }
.pg-desc strong{ color:var(--lg2-fg); }
.pg-desc ul{ list-style:none; margin:0 0 14px; padding:0; display:grid; gap:9px; }
.pg-desc ul li{ display:flex; gap:10px; align-items:flex-start; font-size:14.5px; color:var(--lg2-fg-dim); line-height:1.55; }
.pg-desc ul li::before{
content:""; flex:none; width:7px; height:7px; border-radius:2px; background:var(--lg2-accent);
margin-top:7px; transform:rotate(45deg);
}
.pg-desc hr{ border:none; border-top:1px solid var(--lg2-line-soft); margin:22px 0; }
.pg-side{
background:var(--lg2-panel); border:1px solid var(--lg2-line); border-radius:12px;
padding:22px; position:sticky; top:20px; align-self:start;
}
.pg-side h3{
font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.08em; text-transform:uppercase;
color:var(--lg2-fg-faint); margin:0 0 14px;
}
.pg-side-row{ display:flex; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid var(--lg2-line-soft); font-size:13.5px; }
.pg-side-row:last-of-type{ border-bottom:none; }
.pg-side-row span:first-child{ color:var(--lg2-fg-faint); }
.pg-side-row span:last-child{ color:var(--lg2-fg); font-weight:600; text-align:right; }
.pg-side-cta{ width:100%; margin-top:16px; justify-content:center; }
.pg-catalog{ max-width:1180px; margin:0 auto 60px; padding:0 clamp(20px,4vw,40px); }
.pg-catalog-head{ display:flex; align-items:baseline; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.pg-catalog-head h2{ font:700 18px/1 "Ubuntu",sans-serif; margin:0; color:var(--lg2-fg); }
.pg-catalog-head a{ font:600 13px/1 "Ubuntu",sans-serif; color:var(--lg2-accent); text-decoration:none; }
.pg-catalog-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:10px; }
.pg-chip{
display:flex; align-items:center; gap:10px; background:var(--lg2-panel); border:1px solid var(--lg2-line);
border-radius:9px; padding:10px 12px; text-decoration:none; color:var(--lg2-fg-dim);
font:600 13.5px/1.25 "Ubuntu",sans-serif; transition:border-color .12s;
}
.pg-chip:hover{ border-color:var(--lg2-accent); color:var(--lg2-fg); }
.pg-chip img{ width:22px; height:22px; object-fit:contain; flex:none; }
.pg-footer{
border-top:1px solid var(--lg2-line); padding:20px clamp(20px,4vw,40px); text-align:center;
font:400 12.5px/1 "Ubuntu",sans-serif; color:var(--lg2-fg-faint);
}
.pg-footer a{ color:var(--lg2-fg-faint); text-decoration:none; }
/* Re-skin the shared access-modal component (same as login.html). */
.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; }
</style>
</head>
<body class="product-page">
<header class="pg-header">
<a class="pg-brand" href="/">
<img src="/static/logo-color.png" alt="MONT" />
<span>Инфраструктурный полигон</span>
</a>
<div class="pg-header-actions">
<a class="pg-btn pg-btn-ghost" href="/#products">Все продукты</a>
<a class="pg-btn pg-btn-accent" href="/">Войти в полигон</a>
</div>
</header>
<nav class="pg-crumbs">
<a href="/">Полигон</a><span class="sep">/</span>
<span class="current">{{ service.name }}</span>
</nav>
<div class="pg-hero">
<div class="pg-plate">
<img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="{{ service.name }}" />
</div>
<div>
<div class="pg-eyebrow">Доступен на полигоне{% if cat_names %} · {{ cat_names|join(', ') }}{% endif %}</div>
<h1>{{ service.name }}</h1>
<p class="pg-lede">{{ meta_description }}</p>
<div class="pg-actions">
<button class="pg-btn pg-btn-accent" type="button" data-open-access-modal="1" data-preselect="{{ service.name }}">Запросить тестовый доступ →</button>
<span class="pg-note">Доступ откроется в течение рабочего дня, бесплатно</span>
</div>
<div class="pg-prompt">
<span class="p2">mont@polygon:~/catalog$</span> <span class="p1">./access.sh --product {{ service.slug }}</span><span class="pg-cursor"></span>
</div>
</div>
</div>
<div class="pg-layout">
<div>
<div class="pg-card pg-desc">
{% if description_html %}
{{ description_html }}
{% else %}
<p>{{ service.name }} доступен для тестирования на инфраструктурном полигоне MONT — оставьте заявку, и мы откроем доступ к рабочему стенду.</p>
{% endif %}
</div>
</div>
<aside>
<div class="pg-side">
<h3>Карточка доступа</h3>
{% if cat_names %}
<div class="pg-side-row"><span>Категория</span><span>{{ cat_names|join(', ') }}</span></div>
{% endif %}
<div class="pg-side-row"><span>Тип сессии</span><span>{{ service.type.value }}</span></div>
<button class="pg-btn pg-btn-accent pg-side-cta" type="button" data-open-access-modal="1" data-preselect="{{ service.name }}">Запросить доступ</button>
</div>
</aside>
</div>
{% if other_services %}
<div class="pg-catalog">
<div class="pg-catalog-head">
<h2>Другие продукты полигона</h2>
<a href="/#products">Смотреть весь каталог →</a>
</div>
<div class="pg-catalog-grid">
{% for svc in other_services %}
<a class="pg-chip" href="/product/{{ svc.slug }}">
<img src="{{ svc.icon_path or '/static/service-placeholder.svg' }}" alt="" />
{{ svc.name }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<footer class="pg-footer">
<a href="mailto:ruslan@ipcom.su">Made by Galyaviev</a>
</footer>
<div id="access-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">
<div class="access-field">
<label>Имя и фамилия <span class="req">*</span></label>
<input id="am-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Название компании <span class="req">*</span></label>
<input id="am-company" type="text" placeholder="ООО Компания" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="am-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Ваш менеджер в MONT</label>
<input id="am-manager" type="text" placeholder="Если известно — укажите имя" />
</div>
<div class="access-field">
<label style="display:flex;align-items:center;justify-content:space-between">
Интересующие продукты
<label id="am-select-all-wrap" style="display:none;font-weight:400;font-size:13px;cursor:pointer;gap:6px;align-items:center">
<input type="checkbox" id="am-select-all" style="cursor:pointer" />
<span>Выбрать все</span>
</label>
</label>
<div id="am-products" class="access-products-wrap">
<div class="access-products-loading">Загрузка...</div>
</div>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="am-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="am-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer">
<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('access-modal');
const btnCancel = document.getElementById('am-cancel');
const btnSubmit = document.getElementById('am-submit');
const errEl = document.getElementById('am-error');
let productsLoaded = false;
let pendingPreselect = '';
function resetAccessForm() {
if (!document.getElementById('am-name')) {
document.querySelector('.access-modal-body').innerHTML = `
<div class="access-field"><label>Имя и фамилия <span class="req">*</span></label><input id="am-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Название компании <span class="req">*</span></label><input id="am-company" type="text" placeholder="ООО Компания" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="am-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Ваш менеджер в MONT</label><input id="am-manager" type="text" placeholder="Если известно — укажите имя" /></div>
<div class="access-field"><label>Интересующие продукты</label><div id="am-products" class="access-products-wrap"><div class="access-products-loading">Загрузка...</div></div></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="am-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="am-error" class="access-modal-error" style="display:none"></div>`;
document.querySelector('.access-modal-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button><button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>`;
document.getElementById('am-cancel').addEventListener('click', closeModal);
document.getElementById('am-submit').addEventListener('click', submitForm);
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
productsLoaded = false;
} else {
['am-name','am-company','am-email','am-phone','am-manager'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked=false; });
var saBox = document.getElementById('am-select-all'); if (saBox) saBox.checked = false;
var err=document.getElementById('am-error'); if(err) err.style.display='none';
var btn=document.getElementById('am-submit'); if(btn){btn.disabled=false;btn.textContent='Запросить доступ';}
}
}
function openModal(preselect) {
pendingPreselect = preselect || '';
resetAccessForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!productsLoaded) loadProducts();
else applyPreselect();
}
function applyPreselect() {
if (!pendingPreselect) return;
const cb = document.querySelector('#am-products input[type=checkbox][value="' + pendingPreselect.replace(/"/g,'\\"') + '"]');
if (cb) cb.checked = true;
}
function closeModal() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('.am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeAccessModal = function() { closeModal(); productsLoaded = false; };
async function loadProducts() {
const wrap = document.getElementById('am-products');
try {
const res = await fetch('/api/public/services-by-category');
const data = await res.json();
wrap.innerHTML = '';
for (const [cat, svcs] of Object.entries(data)) {
const group = document.createElement('div');
group.className = 'access-products-group';
group.innerHTML = '<div class="access-products-cat">' + cat + '</div>';
for (const svc of svcs) {
const lbl = document.createElement('label');
lbl.className = 'access-product-item';
lbl.innerHTML = '<input type="checkbox" value="' + svc.name.replace(/"/g, '&quot;') + '" /><span>' + svc.name + '</span>';
group.appendChild(lbl);
}
wrap.appendChild(group);
}
productsLoaded = true;
applyPreselect();
const saWrap = document.getElementById('am-select-all-wrap');
if (saWrap) saWrap.style.display = 'flex';
const saBox = document.getElementById('am-select-all');
if (saBox) {
saBox.addEventListener('change', function() {
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked = saBox.checked; });
});
wrap.addEventListener('change', function(e) {
if (e.target === saBox) return;
const all = [...document.querySelectorAll('#am-products input[type=checkbox]')];
saBox.checked = all.length > 0 && all.every(function(cb){ return cb.checked; });
});
}
} catch(e) {
wrap.innerHTML = '<div class="access-products-loading">Не удалось загрузить список</div>';
}
}
async function submitForm() {
const nameEl = document.getElementById('am-name');
const companyEl = document.getElementById('am-company');
const emailEl = document.getElementById('am-email');
const phoneEl = document.getElementById('am-phone');
const managerEl = document.getElementById('am-manager');
const submitBtn = document.getElementById('am-submit');
const errorEl = document.getElementById('am-error');
const name = nameEl ? nameEl.value.trim() : '';
const company = companyEl ? companyEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const manager = managerEl ? managerEl.value.trim() : '';
const checked = [...document.querySelectorAll('#am-products input[type=checkbox]:checked')];
const products = checked.map(c => c.value);
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('am-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя и фамилию' },
{ el: companyEl, check: () => !!company, msg: 'Введите название компании' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), 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('.access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('.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/request-access', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, company, email, phone, manager, products}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
const body = document.querySelector('#access-modal .access-modal-body');
const footer = document.querySelector('#access-modal .access-modal-footer');
body.innerHTML = '<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Запрос отправлен</div>' +
'<div class="am-success-sub">После утверждения доступы придут на электронную почту <strong>' + email + '</strong></div>' +
'</div>';
footer.innerHTML = '<button type="button" class="access-btn-cancel" onclick="window._closeAccessModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('am-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('am-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Запросить доступ'; }
}
}
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
document.querySelectorAll('[data-open-access-modal]').forEach(el => {
el.addEventListener('click', function(e) {
e.preventDefault();
openModal(el.getAttribute('data-preselect') || '');
});
});
btnCancel.addEventListener('click', closeModal);
btnSubmit.addEventListener('click', submitForm);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
})();
</script>
</body>
</html>