Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions admin/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,73 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import { readFileSync, statSync } from 'node:fs'
import { extname, resolve } from 'node:path'
import { defineConfig, loadEnv, type PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue'

/**
* Dev-only: serve site/ landing on /, /en/, /kk/ + its assets — same as nginx does in prod.
* SPA stays on /admin/*. No effect on production build (vite build emits only the SPA).
*/
function landingDevPlugin(): PluginOption {
const siteRoot = resolve(__dirname, '..', 'site')
const mime: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
}
// Files under site/ root that should be reachable as /<name>. Anything not in this
// set (e.g. /admin/, /v1/, /health, HMR) falls through to Vite / existing proxies.
const rootAssets = new Set(['styles.css', 'main.js', 'favicon.svg', 'robots.txt', 'sitemap.xml'])

function tryServe(reqPath: string): { body: Buffer; type: string } | null {
// Normalize: strip query/hash, decode, drop leading slash.
const clean = decodeURIComponent(reqPath.split('?')[0].split('#')[0])
let rel: string | null = null
if (clean === '/' || clean === '/index.html') rel = 'index.html'
else if (clean === '/en' || clean === '/en/' || clean === '/en/index.html') rel = 'en/index.html'
else if (clean === '/kk' || clean === '/kk/' || clean === '/kk/index.html') rel = 'kk/index.html'
else if (clean.startsWith('/')) {
const name = clean.slice(1)
if (rootAssets.has(name)) rel = name
}
if (!rel) return null
const full = resolve(siteRoot, rel)
if (!full.startsWith(siteRoot)) return null // path traversal guard
try {
const st = statSync(full)
if (!st.isFile()) return null
return { body: readFileSync(full), type: mime[extname(full).toLowerCase()] || 'application/octet-stream' }
} catch {
return null
}
}

return {
name: 'landing-dev-serve',
apply: 'serve',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (!req.url || req.method !== 'GET') return next()
// Anything under /admin/, /v1/, /health, /webhooks → let proxy / SPA handle it.
if (/^\/(admin|v1|health|webhooks|@vite|@id|@fs|node_modules|src)(\/|$|\?)/.test(req.url)) return next()
const hit = tryServe(req.url)
if (!hit) return next()
res.statusCode = 200
res.setHeader('Content-Type', hit.type)
res.setHeader('Cache-Control', 'no-store')
res.end(hit.body)
})
},
}
}

// API path segments that should be proxied to the orchestrator.
// Everything else under /admin/ is served by Vite (SPA, HMR, assets).
const apiSegments = [
Expand All @@ -19,7 +85,7 @@ export default defineConfig(({ mode }) => {
const isDemo = env.VITE_DEMO_MODE === 'true'

return {
plugins: [vue()],
plugins: [vue(), landingDevPlugin()],
base: env.VITE_BASE_PATH || (isDemo ? '/' : '/admin/'),
resolve: {
alias: {
Expand Down
6 changes: 3 additions & 3 deletions site/en/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<meta name="twitter:description" content="Virtual AI assistant: Telegram, WhatsApp, web, phone. 14-day trial." />
<meta name="twitter:image" content="https://ai-sekretar24.ru/og-image.png" />

<link rel="stylesheet" href="/styles.css?v=2" />
<link rel="stylesheet" href="/styles.css?v=3" />

<script type="application/ld+json">
{
Expand Down Expand Up @@ -107,7 +107,7 @@
<nav class="lang-switcher" aria-label="Language">
<a href="/" hreflang="ru" lang="ru">RU</a>
<a href="/en/" hreflang="en" lang="en" class="is-active" aria-current="page">EN</a>
<a href="/kk/" hreflang="kk" lang="kk">KK</a>
<a href="/kk/" hreflang="kk" lang="kk">KZ</a>
</nav>

<button class="burger" id="burger" aria-label="Menu" aria-expanded="false">
Expand Down Expand Up @@ -415,6 +415,6 @@ <h4>Legal</h4>
</div>
</footer>

<script src="/main.js?v=2" defer></script>
<script src="/main.js?v=3" defer></script>
</body>
</html>
6 changes: 3 additions & 3 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<meta name="twitter:description" content="Виртуальный AI-ассистент: Telegram, WhatsApp, сайт, телефония. Триал 14 дней." />
<meta name="twitter:image" content="https://ai-sekretar24.ru/og-image.png" />

<link rel="stylesheet" href="/styles.css?v=2" />
<link rel="stylesheet" href="/styles.css?v=3" />

<!-- SEO: структурированные данные -->
<script type="application/ld+json">
Expand Down Expand Up @@ -135,7 +135,7 @@
<nav class="lang-switcher" aria-label="Язык / Language">
<a href="/" hreflang="ru" lang="ru" class="is-active" aria-current="page">RU</a>
<a href="/en/" hreflang="en" lang="en">EN</a>
<a href="/kk/" hreflang="kk" lang="kk">KK</a>
<a href="/kk/" hreflang="kk" lang="kk">KZ</a>
</nav>

<button class="burger" id="burger" aria-label="Меню" aria-expanded="false">
Expand Down Expand Up @@ -458,6 +458,6 @@ <h4>Правовое</h4>
</div>
</footer>

<script src="/main.js?v=2" defer></script>
<script src="/main.js?v=3" defer></script>
</body>
</html>
6 changes: 3 additions & 3 deletions site/kk/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<meta name="twitter:description" content="Виртуалды AI-көмекші: Telegram, WhatsApp, сайт, телефон. 14 күн триал." />
<meta name="twitter:image" content="https://ai-sekretar24.ru/og-image.png" />

<link rel="stylesheet" href="/styles.css?v=2" />
<link rel="stylesheet" href="/styles.css?v=3" />

<script type="application/ld+json">
{
Expand Down Expand Up @@ -107,7 +107,7 @@
<nav class="lang-switcher" aria-label="Language">
<a href="/" hreflang="ru" lang="ru">RU</a>
<a href="/en/" hreflang="en" lang="en">EN</a>
<a href="/kk/" hreflang="kk" lang="kk" class="is-active" aria-current="page">KK</a>
<a href="/kk/" hreflang="kk" lang="kk" class="is-active" aria-current="page">KZ</a>
</nav>

<button class="burger" id="burger" aria-label="Мәзір" aria-expanded="false">
Expand Down Expand Up @@ -415,6 +415,6 @@ <h4>Құқықтық</h4>
</div>
</footer>

<script src="/main.js?v=2" defer></script>
<script src="/main.js?v=3" defer></script>
</body>
</html>
19 changes: 18 additions & 1 deletion site/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,28 @@
onScroll();

/* --- Мобильное меню --- */
var nav = document.getElementById('nav');

function syncMobileNavHeight() {
/* CSS использует --mobile-nav-h для позиционирования actions под пунктами nav,
чтобы не зависеть от magic numbers и количества ссылок. */
if (!nav) return;
var h = nav.getBoundingClientRect().height || 0;
if (h > 0) header.style.setProperty('--mobile-nav-h', h + 'px');
}

burger.addEventListener('click', function () {
var open = header.classList.toggle('nav-open');
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) {
/* Меряем после применения класса, чтобы nav уже был развёрнут. */
requestAnimationFrame(syncMobileNavHeight);
}
});
window.addEventListener('resize', function () {
if (header.classList.contains('nav-open')) syncMobileNavHeight();
});
document.querySelectorAll('#nav a, .header__actions a').forEach(function (link) {
document.querySelectorAll('#nav a, .header__actions a, .lang-switcher a').forEach(function (link) {
link.addEventListener('click', function () {
header.classList.remove('nav-open');
burger.setAttribute('aria-expanded', 'false');
Expand Down
90 changes: 75 additions & 15 deletions site/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

* { box-sizing: border-box; margin: 0; padding: 0; }
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }

html { scroll-behavior: smooth; scroll-padding-top: 88px; }

Expand All @@ -33,6 +33,7 @@ body {
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
padding-bottom: env(safe-area-inset-bottom);
}

a { color: inherit; text-decoration: none; }
Expand Down Expand Up @@ -66,6 +67,7 @@ h1, h2, h3, h4 { line-height: 1.2; font-weight: 700; }
font-weight: 600;
font-size: 15px;
padding: 11px 20px;
min-height: 44px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
Expand Down Expand Up @@ -94,6 +96,7 @@ h1, h2, h3, h4 { line-height: 1.2; font-weight: 700; }
z-index: 100;
transition: background .25s ease, border-color .25s ease, backdrop-filter .25s ease;
border-bottom: 1px solid transparent;
padding-top: env(safe-area-inset-top);
}
.header.scrolled {
background: rgba(9, 9, 11, .85);
Expand Down Expand Up @@ -182,7 +185,7 @@ h1, h2, h3, h4 { line-height: 1.2; font-weight: 700; }
}
.hero__title { font-size: clamp(32px, 6vw, 56px); font-weight: 800; letter-spacing: -.02em; }
.hero__subtitle {
font-size: clamp(16px, 2.3vw, 19px);
font-size: clamp(17px, 2.3vw, 19px);
color: var(--muted);
max-width: 680px;
margin: 22px auto 0;
Expand Down Expand Up @@ -515,37 +518,94 @@ h1, h2, h3, h4 { line-height: 1.2; font-weight: 700; }
@media (max-width: 720px) {
.nav, .header__actions { display: none; }
.burger { display: flex; }
.header.nav-open .nav {

/* Мобильное меню: drawer для nav и actions; lang-switcher и бургер остаются
в верхней строке рядом с логотипом. */
.header.nav-open {
background: rgba(9, 9, 11, .96);
backdrop-filter: blur(12px);
border-bottom-color: var(--border);
}
.header.nav-open .nav,
.header.nav-open .header__actions {
display: flex;
position: absolute;
top: 72px; left: 0; right: 0;
left: 0; right: 0;
flex-direction: column;
background: rgba(9, 9, 11, .98);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 16px 20px;
gap: 4px;
padding: 0 20px;
margin: 0;
border-radius: 0;
}
.header.nav-open .nav a { padding: 12px 0; font-size: 16px; }
.header.nav-open .header__actions {
.header.nav-open .nav {
top: calc(72px + env(safe-area-inset-top));
padding-top: 12px;
gap: 4px;
}
.header.nav-open .nav a {
padding: 14px 0;
font-size: 17px;
min-height: 48px;
display: flex;
position: absolute;
top: calc(72px + 220px); left: 0; right: 0;
flex-direction: column;
background: rgba(9, 9, 11, .98);
padding: 0 20px 20px;
align-items: center;
border-bottom: 1px solid var(--border);
}
.header.nav-open .nav a:last-child { border-bottom: 0; }
.header.nav-open .header__actions {
/* Стек прямо под nav. Расчёт через CSS-vars из JS (см. main.js: --mobile-nav-h). */
top: calc(72px + env(safe-area-inset-top) + var(--mobile-nav-h, 240px));
gap: 10px;
padding-bottom: 16px;
}
.header.nav-open .header__actions .btn { width: 100%; }

/* В закрытом виде lang-switcher скрыт, чтобы не конкурировал с бургером за место. */
.lang-switcher { display: none; }
/* В открытом — встаёт в верхнюю строку: лого слева, lang-switcher по центру, бургер справа. */
.header.nav-open .lang-switcher {
display: flex;
margin-left: auto;
margin-right: 8px;
}
/* В открытом меню auto-margin у lang-switcher уже растягивает пробел; auto у бургера
лишний и съест место. В закрытом — оставляем auto, чтобы бургер был справа. */
.header.nav-open .burger { margin-left: 0; }

.grid-3, .grid-4 { grid-template-columns: 1fr; }
.section { padding: 60px 0; }
.hero { padding: 56px 0 64px; }
.plan-enterprise { flex-direction: column; align-items: flex-start; }
.hero__cta { flex-direction: column; align-items: stretch; }
.hero__cta .btn { width: 100%; }
.plan { padding: 26px 20px; }
.plan-enterprise { flex-direction: column; align-items: flex-start; padding: 26px 22px; }
.pain, .feature, .role-card, .step { padding: 22px 20px; }
.faq-item summary { padding: 18px 0; min-height: 52px; }
.cta-card { padding: 36px 22px; }
.lead-form__row { flex-direction: column; }
.footer__inner { grid-template-columns: 1fr; }
.footer__bottom { flex-direction: column; }
}

/* Узкие экраны (iPhone SE, базовый Android) */
@media (max-width: 480px) {
.container { padding: 0 16px; }
.header__inner { gap: 12px; height: 64px; }
html { scroll-padding-top: 80px; }
.logo__text { font-size: 17px; }
.logo__mark { width: 28px; height: 28px; }
.hero { padding: 44px 0 52px; }
.hero__badge { font-size: 12px; padding: 6px 14px; margin-bottom: 20px; }
.hero__title { font-size: clamp(28px, 8vw, 36px); }
.hero__channels-list { gap: 8px; }
.chip { font-size: 13px; padding: 7px 13px; }
.section { padding: 48px 0; }
.section__title { font-size: clamp(24px, 6.5vw, 30px); }
.plan__amount { font-size: 32px; }
.header.nav-open .nav { top: calc(64px + env(safe-area-inset-top)); }
.header.nav-open .header__actions { top: calc(64px + env(safe-area-inset-top) + var(--mobile-nav-h, 240px)); }
}

@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; transition: none !important; }
}
Loading