Rename medcenter landings and add the revenue calculator

Rename the two medcenter landings to their audience names:
medcenter -> medcenterphysio, medcenterpersonal -> medcenterstart.

Alongside the rename:
- add a RevenueCalculator section to both landings;
- rework the copy and figures in src/data/content.ts;
- simplify the lead form: drop the "cabinet_state" and "profile"
  selects (along with SelectField and the matching fields in
  shared/lead.ts, lead-mapper.ts and amo-check.ts) and make
  company and email optional;
- add the legacy/new static prototypes for both landings;
- add pnpm-lock.yaml to medcenterstart (package-lock.json is still
  there too).

deploy/apps.conf and deploy/README.md still refer to the old
directory names and need a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-06 23:19:46 +06:00
co-authored by Claude Opus 5
parent be7741e269
commit 0732aa2096
170 changed files with 5342 additions and 205 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "medcenter-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:client"],
"port": 5173
}
]
}
+36
View File
@@ -0,0 +1,36 @@
# ---------------------------------------------------------------------------
# Public (baked into the client bundle at build time — never put secrets here)
# ---------------------------------------------------------------------------
VITE_SITE_URL=https://exodevices.ru/medical-centers/with-physiotherapy
# Sub-path the site is served from, if any. Leave as "/" when it sits at the
# domain root; set to "/medical-centers/with-physiotherapy/" when it is mounted
# under that path.
VITE_BASE_PATH=/
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
# One port per landing so all four can run side by side:
# fitnes 3000, hotel 3001, medcenter 3002, medcenterpersonal 3003.
PORT=3002
# ---------------------------------------------------------------------------
# amoCRM — server-side only, never exposed to the browser
# ---------------------------------------------------------------------------
# Your account subdomain: for https://exotherapy.amocrm.ru put "exotherapy"
AMO_SUBDOMAIN=
# Long-lived access token.
# amoCRM → Настройки → Интеграции → Создать интеграцию → Внешняя интеграция →
# open it → tab "Ключи и скопы" → «Генерировать токен» (долгосрочный, ~1 год).
AMO_LONG_LIVED_TOKEN=
# Pipeline (воронка) the leads land in. The lead is placed in its first stage.
AMO_PIPELINE_ID=10980758
# Optional: numeric id of the amoCRM user leads are assigned to.
AMO_RESPONSIBLE_USER_ID=
# Optional: comma-separated tags added to every lead.
AMO_LEAD_TAGS=medcenter-landing
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.local
.DS_Store
+1
View File
@@ -0,0 +1 @@
24
+149
View File
@@ -0,0 +1,149 @@
# Усиление действующего кабинета физиотерапии — лендинг для медицинских центров
React-приложение лендинга MedCsFiz (медицинский центр, у которого физиотерапия
уже есть). Каждая заявка создаёт сделку в amoCRM (воронка `10980758`) и контакт,
если его ещё нет.
Стек: **Vite + React 19 + TypeScript + Tailwind CSS v4**, API — **Express 5**.
---
## Быстрый старт
```bash
npm install
cp .env.example .env # заполнить AMO_SUBDOMAIN и AMO_LONG_LIVED_TOKEN
npm run dev
```
`npm run dev` поднимает Vite на `http://localhost:5173` и API на `:3000`;
запросы `/api/*` проксируются с фронтенда на API.
## Продакшен
```bash
npm run build # сборка клиента в dist/client
npm start # Express отдаёт dist/client и обрабатывает /api/*
```
Один процесс Node на любом VPS. Порт — `PORT` (по умолчанию 3000).
---
## amoCRM
### 1. Получить долгосрочный токен
1. amoCRM → **Настройки → Интеграции → Создать интеграцию → Внешняя интеграция**.
2. Название любое (например «Сайт medcenter»), redirect URI — любой рабочий
адрес сайта: для долгосрочного токена он не используется.
3. Права доступа: достаточно **Сделки, Контакты** (чтение и запись).
4. Сохранить, открыть интеграцию → вкладка **«Ключи и скопы»** →
**«Генерировать токен»** (долгосрочный, действует ~1 год).
5. Скопировать токен в `.env` в `AMO_LONG_LIVED_TOKEN`, поддомен — в
`AMO_SUBDOMAIN` (для `https://exotherapy.amocrm.ru` это `exotherapy`).
Токен читается только на сервере и никогда не попадает в браузерный бандл.
Пометьте в календаре дату истечения — токен нужно перевыпустить через год.
### 2. Проверить подключение
```bash
npm run amo:check
```
Скрипт выведет название аккаунта, первый этап воронки `10980758`, в который
попадут сделки, и то, в какие поля вашего аккаунта легли данные формы.
### 3. Что происходит при отправке формы
1. Поиск контакта по телефону (во всех написаниях: `+7…`, `8…`, только цифры)
и по email. Если контакт найден — используется он, недостающий телефон или
email дописывается в карточку.
2. Если контакта нет — создаётся новый с именем, телефоном и email.
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`.
4. Данные, для которых в аккаунте есть подходящее поле (компания, состояние
кабинета, профиль, `utm_*` и т.д.), пишутся в поля; всё остальное — в
примечание к сделке. Ничего настраивать в amoCRM заранее не нужно.
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи.
### Переменные окружения
| Переменная | Обязательна | Описание |
|---|---|---|
| `AMO_SUBDOMAIN` | да | Поддомен аккаунта, например `exotherapy` |
| `AMO_LONG_LIVED_TOKEN` | да | Долгосрочный токен интеграции |
| `AMO_PIPELINE_ID` | нет | Воронка для сделок (по умолчанию `10980758`) |
| `AMO_RESPONSIBLE_USER_ID` | нет | Ответственный за сделку пользователь |
| `AMO_LEAD_TAGS` | нет | Теги через запятую для всех сделок |
| `PORT` | нет | Порт API/продакшен-сервера (`3000`) |
| `VITE_SITE_URL` | нет | Канонический адрес, подставляется в `canonical` и `og:` |
| `VITE_BASE_PATH` | нет | Подпуть размещения, например `/medical-centers/with-physiotherapy/` (по умолчанию `/`) |
---
## Структура
```
index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
hooks/useReveal.ts анимация появления секций при скролле
lib/ отправка заявки, маска телефона
assets/images/ 12 изображений, извлечённых из исходного HTML
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM
legacy/index.html исходный однофайловый лендинг (визуальный эталон)
```
### Дизайн-система
Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: `sm` 600, `md` 760, `lg` 980, плюс
`max-width`-варианты `stack` (1050), `narrow` (760), `compact` (560),
`phone` (520), `tiny` (430). Они объявлены от широкого к узкому — Tailwind
выводит варианты в порядке объявления, и более узкий должен идти позже, чтобы
выигрывать.
Горизонтальные ряды карточек (рынок, обещания, оборудование, программы,
форматы, план) используют класс `.scroll-cards`: на телефоне это лента со
snap-скроллом, а на нужной ширине секция сама возвращает её в обычную сетку
через `sm:grid-still` / `md:grid-still`.
### Шрифты
Inter подключён локально пакетом `@fontsource-variable/inter` — внешних запросов
(Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном
100–900, поэтому нестандартные веса из макета (720, 760, 790, 850) отрисовываются
по-настоящему, а не округляются до `bold`.
Символы `≈ ×` не входят в подсеты Inter у Fontsource, поэтому для них всегда
используется системный фолбэк. Это заметно только при очень крупном кегле.
`Manrope` и `Segoe UI` остались в стеке `--font-sans` как фолбэк, но отдельно не
загружаются.
### Экономика кабинета
Все цифры блоков «Деньги уходят мимо Вас» и «Сценарии выручки» — статические
данные из `src/data/content.ts`, как в исходном лендинге. Калькулятора на
странице нет: расчёт делается на аудите.
---
## Известные особенности
* `og:image` отдаётся по абсолютному адресу `VITE_SITE_URL` + `/og-image.webp`;
при смене домена обновите `VITE_SITE_URL` перед сборкой.
* Две фотографии аппаратов (ЭкзоЛазер В и ЭкзоИмпульс) в исходном файле были
PNG на 1,5 МБ и 750 КБ — здесь они пережаты в webp с сохранением
прозрачности, как и остальные снимки оборудования.
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Усиление действующего кабинета физиотерапии | ЭКЗО Групп</title>
<meta
name="description"
content="Аудит и модернизация действующего кабинета физиотерапии: сохраняем полезное оборудование, убираем дублирование, добавляем недостающие технологии ЭКЗО, объединяем старое и новое в маршруты и подтверждаем модель пилотом за 60 дней."
/>
<meta name="theme-color" content="#0A2540" />
<meta name="robots" content="index,follow" />
<link rel="canonical" href="%VITE_SITE_URL%" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="ЭКЗО Групп" />
<meta property="og:title" content="Усиление действующей физиотерапии — без замены всего парка" />
<meta
property="og:description"
content="Аудит оборудования и документов, интеграция старых и новых технологий, клинические маршруты, обучение, аналитика и пилот «до / после»."
/>
<meta property="og:url" content="%VITE_SITE_URL%" />
<meta property="og:image" content="%VITE_SITE_URL%/og-image.webp" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Усиление действующего кабинета физиотерапии — ЭКЗО Групп" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Service",
"name": "Усиление действующего кабинета физиотерапии",
"provider": { "@type": "Organization", "name": "Экзо Групп" },
"serviceType": "Аудит и модернизация кабинета физиотерапии медицинского центра",
"areaServed": "RU",
"url": "%VITE_SITE_URL%"
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
Загрузить index.html, styles.css, script.js и папку assets/ в один каталог на сервере. Не менять относительные пути.
+29
View File
@@ -0,0 +1,29 @@
EXO MedCsFiz — server codebase v3
EXO MedCsFiz — кодовая база для сервера
Структура:
- index.html — разметка страницы
- styles.css — стили
- script.js — логика страницы, формы и калькулятора
- assets/ — изображения и графика
Изменения версии:
- навигация: Рынок / Экономика / Оборудование / Модели запуска
- калькулятор выручки, окупаемости и ROI в блоке «Сценарии выручки»
- обязательные поля формы: Имя и Телефон
- Компания / медицинский центр и Email — необязательные
- удалены поля «Состояние кабинета» и «Основной профиль»
Размещение:
Загрузите всё содержимое этой папки в корень маршрута лендинга, сохранив структуру assets/.
Форма отправляет данные на endpoint: /api/leads/medical-centers-existing-physio
## v6
- Удалено неподтверждённое утверждение о маржинальности 60–70%.
- На первом экране добавлен хук ≈20 млн ₽ потенциально упущенной выручки в сценарной модели.
- В экономическом блоке показатель маржинальности заменён на 4 цикла повторного маршрута пациента в год.
Version v7: mobile typography/density audit; compact financial formula, section headings, calculator, roadmap and CTA on small screens.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="330" height="88" viewBox="0 0 330 88" role="img" aria-label="Участник проекта Сколково">
<rect width="330" height="88" rx="16" fill="#ffffff"/>
<g transform="translate(16 19) scale(1.02)">
<path fill="#b1ec52" d="M12.31 0C5.51 0 0 5.51 0 12.31v24.62c0 6.8 5.51 12.31 12.31 12.31h24.62c6.8 0 12.31-5.51 12.31-12.31V0H12.31Z"/>
<path fill="#4d5759" d="M40.69 18.46h-5.91l-5.62 6.16V12.31H24.6v2.98c-.15-.19-.31-.38-.48-.57-1.66-1.76-4.05-2.65-7.11-2.65-3.53 0-5.54 1.58-6.61 2.91-1.32 1.65-1.91 3.9-1.5 5.73 1.06 4.74 5.46 5.5 8.69 5.92 2.55.33 4.99.8 4.93 3.07-.07 2.36-2.73 2.94-4.48 2.94-4.55 0-4.7-3.36-4.7-3.36H8.45c.07 1.26.45 3.29 2.01 5.03 1.7 1.91 4.26 2.87 7.59 2.87 2.56 0 4.89-.83 6.55-2.31v2.03h4.56v-5.81l1.61-1.77 4.61 7.58h5.31l-6.71-11.09 6.71-7.35ZM13.46 19.66c-.4-1.76 1.35-3.32 3.63-3.32 2.05 0 4.1.46 4.88 3.31h2.62v4.36c-3.27-2.69-10.37-1-11.13-4.35Z"/>
</g>
<text x="82" y="37" font-family="Arial, Helvetica, sans-serif" font-size="22" font-weight="700" letter-spacing="0.6" fill="#4d5759">УЧАСТНИК</text>
<text x="82" y="59" font-family="Arial, Helvetica, sans-serif" font-size="13" font-weight="600" letter-spacing="0.7" fill="#4d5759">ПРОЕКТА «СКОЛКОВО»</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long
+137
View File
@@ -0,0 +1,137 @@
const FORM_ENDPOINT = '/api/leads/medical-centers-existing-physio';
const header = document.getElementById('header');
const updateHeader = () => header.classList.toggle('scrolled', window.scrollY > 18);
updateHeader();
window.addEventListener('scroll', updateHeader, {passive:true});
const revealObserver = 'IntersectionObserver' in window ? new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); }
});
}, {threshold:.12, rootMargin:'0px 0px -42px'}) : null;
document.querySelectorAll('[data-reveal]').forEach((el, i) => {
el.style.transitionDelay = `${Math.min((i % 5) * 55, 220)}ms`;
if (revealObserver) revealObserver.observe(el); else el.classList.add('is-visible');
});
const counterObserver = 'IntersectionObserver' in window ? new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const el = entry.target;
const target = parseFloat(el.dataset.counter || '0');
const suffix = el.dataset.suffix || '';
const prefix = el.dataset.prefix || '';
const decimals = String(target).includes('.') ? 1 : 0;
const start = performance.now();
const duration = 950;
const tick = now => {
const p = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - p, 3);
const v = target * eased;
el.textContent = prefix + (target === 2.41 ? v.toFixed(2) : v.toFixed(decimals)).replace('.', ',') + suffix;
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
observer.unobserve(el);
});
}, {threshold:.6}) : null;
document.querySelectorAll('[data-counter]').forEach(el => counterObserver ? counterObserver.observe(el) : null);
// Save UTM parameters into hidden fields.
const params = new URLSearchParams(location.search);
['utm_source','utm_medium','utm_campaign','utm_content','utm_term'].forEach(key => {
const input = document.querySelector(`[name="${key}"]`);
let stored = '';
try { stored = sessionStorage.getItem(key) || ''; } catch (e) { stored = ''; }
if (input) input.value = params.get(key) || stored;
if (params.get(key)) { try { sessionStorage.setItem(key, params.get(key)); } catch (e) {} }
});
const phone = document.getElementById('phone');
phone.addEventListener('input', () => {
let d = phone.value.replace(/\D/g,'').slice(0,11);
if (!d) return;
if (d[0] === '8') d = '7' + d.slice(1);
if (d[0] !== '7') d = '7' + d;
let value = '+7';
if (d.length > 1) value += ' ' + d.slice(1,4);
if (d.length >= 5) value += ' ' + d.slice(4,7);
if (d.length >= 8) value += '-' + d.slice(7,9);
if (d.length >= 10) value += '-' + d.slice(9,11);
phone.value = value;
});
function setStatus(message, type) {
const status = document.getElementById('formStatus');
status.textContent = message;
status.className = `form-status show ${type}`;
}
async function submitLead(event) {
event.preventDefault();
const form = event.currentTarget;
const button = document.getElementById('submitBtn');
if (form.website.value) return false;
if (!form.checkValidity()) { form.reportValidity(); setStatus('Проверьте обязательные поля формы.', 'error'); return false; }
const phoneDigits = form.phone.value.replace(/\D/g,'');
if (phoneDigits.length < 11) { setStatus('Укажите полный номер телефона.', 'error'); form.phone.focus(); return false; }
const payload = Object.fromEntries(new FormData(form).entries());
payload.created_at = new Date().toISOString();
payload.page_url = location.href;
payload.page_title = document.title;
button.disabled = true;
button.textContent = 'Отправляем…';
try {
if (location.protocol === 'file:') throw new Error('preview');
const response = await fetch(FORM_ENDPOINT, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus('Спасибо. Заявка отправлена — специалист свяжется с вами для аудита действующего кабинета.', 'success');
form.reset();
} catch (error) {
console.warn('Lead endpoint is not connected in preview:', error);
if (location.protocol === 'file:' || location.hostname === 'localhost') {
setStatus('Демонстрационный режим: форма заполнена корректно. Для публикации подключите FORM_ENDPOINT к CRM или webhook.', 'success');
} else {
setStatus('Не удалось отправить заявку. Попробуйте ещё раз или свяжитесь с компанией по телефону.', 'error');
}
} finally {
button.disabled = false;
button.innerHTML = `Получить предложение <svg viewBox='0 0 24 24' aria-hidden='true'><path d='M5 12h14M13 6l6 6-6 6' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>`;
}
return false;
}
window.submitLead = submitLead;
(function(){
const root=document.querySelector('[data-course-calculator]'); if(!root)return;
const countInput=root.querySelector('#courseCalcCount');
const checkInput=root.querySelector('#courseCalcCheck');
const investmentInput=root.querySelector('#courseCalcInvestment');
const monthOutput=root.querySelector('[data-course-month]');
const yearOutput=root.querySelector('[data-course-year]');
const paybackOutput=root.querySelector('[data-course-payback]');
const roiOutput=root.querySelector('[data-course-roi]');
const money=new Intl.NumberFormat('ru-RU',{maximumFractionDigits:0});
const decimal=new Intl.NumberFormat('ru-RU',{minimumFractionDigits:1,maximumFractionDigits:1});
function safeNumber(input){const value=Number(String(input.value||'').replace(/\s/g,'').replace(',','.'));return Number.isFinite(value)&&value>0?value:0}
function update(){
const courses=safeNumber(countInput), averageCheck=safeNumber(checkInput), investment=safeNumber(investmentInput);
const month=courses&&averageCheck?Math.round(courses*averageCheck):0;
const year=month*12;
monthOutput.textContent=month?money.format(month)+' ₽':'—';
yearOutput.textContent=year?money.format(year)+' ₽':'—';
if(month&&investment){
const payback=investment/month;
paybackOutput.textContent=decimal.format(payback)+' мес.';
const roi=((year-investment)/investment)*100;
roiOutput.textContent=Number.isFinite(roi)?money.format(Math.round(roi))+'%':'—';
} else {paybackOutput.textContent='—';roiOutput.textContent='—';}
}
[countInput,checkInput,investmentInput].forEach(el=>el&&el.addEventListener('input',update));
update();
})();
+825
View File
@@ -0,0 +1,825 @@
:root {
--navy:#0A2540;
--navy-2:#061B2F;
--navy-3:#0E3352;
--teal:#00C4B4;
--teal-2:#45E4D7;
--ice:#EAFBF8;
--white:#FFFFFF;
--ink:#0A2540;
--muted:#60778B;
--muted-dark:#A8BDCB;
--line:rgba(10,37,64,.12);
--line-dark:rgba(255,255,255,.12);
--shadow:0 30px 90px rgba(4,30,48,.16);
--radius-xl:34px;
--radius-lg:25px;
--radius-md:18px;
--container:1200px;
--section-y:clamp(68px,8vw,108px);
}
*,*::before,*::after{box-sizing:border-box}
html{scroll-behavior:smooth;background:var(--navy-2)}
body{margin:0;overflow-x:hidden;color:var(--ink);background:#F5FAFB;font-family:Inter,Manrope,"Segoe UI",Arial,sans-serif;font-size:16px;line-height:1.55;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
body.menu-open{overflow:hidden}
img{display:block;max-width:100%}
a{color:inherit;text-decoration:none}
button,input,textarea,select{font:inherit}
button{cursor:pointer}
::selection{background:rgba(0,196,180,.28);color:var(--navy)}
.container{width:min(calc(100% - 32px),var(--container));margin-inline:auto}
.section{position:relative;padding:var(--section-y) 0}
section[id]{scroll-margin-top:78px}
.section--light{background:#F5FAFB}
.section--white{background:#fff}
.section--dark{background:var(--navy);color:#fff}
.section--compact{--section-y:clamp(58px,6vw,84px)}
.eyebrow{display:inline-flex;align-items:center;gap:10px;margin:0 0 16px;color:#008F84;font-size:12px;font-weight:850;letter-spacing:.14em;text-transform:uppercase}
.eyebrow::before{content:"";width:27px;height:2px;border-radius:2px;background:var(--teal)}
.section--dark .eyebrow{color:var(--teal-2)}
h1,h2,h3,p{margin-top:0}
h1,h2,h3{text-wrap:balance}
h1{max-width:940px;margin-bottom:22px;font-size:clamp(42px,7.7vw,84px);line-height:.98;letter-spacing:-.055em;font-weight:790}
h2{margin-bottom:20px;font-size:clamp(34px,5vw,58px);line-height:1.03;letter-spacing:-.045em;font-weight:760}
h3{margin-bottom:10px;font-size:clamp(21px,2.4vw,28px);line-height:1.14;letter-spacing:-.025em}
.lead{max-width:780px;margin-bottom:0;color:var(--muted);font-size:clamp(18px,2vw,22px);line-height:1.55}
.section--dark .lead{color:rgba(255,255,255,.72)}
.section-head{margin-bottom:clamp(34px,5vw,54px)}
.section-head--split{display:grid;gap:20px}
.small-note{font-size:13px;color:var(--muted);line-height:1.55}
.section--dark .small-note{color:rgba(255,255,255,.56)}
.header{position:fixed;inset:0 0 auto;z-index:60;padding:14px 0;transition:background .25s ease,box-shadow .25s ease,backdrop-filter .25s ease}
.header.scrolled{background:rgba(6,27,47,.88);box-shadow:0 12px 42px rgba(3,20,34,.18);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px)}
.header__inner{display:flex;align-items:center;justify-content:space-between;gap:18px}
.brand{display:inline-flex;align-items:center;min-width:0}
.brand img{width:clamp(165px,19vw,218px);height:auto}
.header__nav{display:none;align-items:center;gap:28px;color:rgba(255,255,255,.76);font-size:14px;font-weight:680}
.header__nav a{transition:color .2s ease}
.header__nav a:hover{color:#fff}
.header__actions{display:flex;align-items:center;gap:10px}
.btn{min-height:52px;padding:0 22px;border:1px solid transparent;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;gap:10px;font-weight:790;line-height:1;transition:transform .2s ease,box-shadow .2s ease,background .2s ease,border-color .2s ease;white-space:nowrap}
.btn svg{width:18px;height:18px;flex:0 0 auto}
.btn:hover{transform:translateY(-2px)}
.btn:active{transform:translateY(0)}
.btn--primary{color:var(--navy);background:var(--teal);box-shadow:0 16px 45px rgba(0,196,180,.26)}
.btn--primary:hover{background:var(--teal-2);box-shadow:0 20px 55px rgba(0,196,180,.34)}
.btn--secondary{color:#fff;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.28);backdrop-filter:blur(10px)}
.btn--secondary:hover{background:rgba(255,255,255,.14);border-color:rgba(255,255,255,.44)}
.btn--ghost{color:var(--navy);background:#fff;border-color:var(--line)}
.btn--header{min-height:44px;padding-inline:17px;font-size:13px}
.mobile-hidden{display:none}
.hero{position:relative;min-height:780px;display:flex;align-items:flex-end;padding:126px 0 48px;overflow:hidden;color:#fff;background:var(--navy-2);isolation:isolate}
.hero::before{content:"";position:absolute;inset:0;z-index:-3;background-image:url("assets/asset_02.webp");background-size:cover;background-position:65% center;transform:scale(1.015)}
.hero::after{content:"";position:absolute;inset:0;z-index:-2;background:linear-gradient(90deg,rgba(4,24,41,.98) 0%,rgba(5,28,47,.92) 40%,rgba(5,28,47,.38) 76%,rgba(5,28,47,.14) 100%),linear-gradient(0deg,rgba(3,20,34,.78),transparent 48%)}
.hero__glow{position:absolute;z-index:-1;inset:auto auto -240px -150px;width:650px;height:650px;border-radius:50%;background:radial-gradient(circle,rgba(0,196,180,.24),transparent 64%);filter:blur(8px)}
.hero__content{max-width:860px}
.hero .eyebrow{color:var(--teal-2)}
.hero__sub{max-width:760px;margin-bottom:28px;color:rgba(255,255,255,.80);font-size:clamp(19px,2vw,24px);line-height:1.52}
.hero__actions{display:flex;flex-direction:column;align-items:stretch;gap:12px;margin-bottom:34px}
.hero__facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;max-width:760px}
.hero-fact{min-height:92px;padding:17px 16px;border:1px solid rgba(255,255,255,.14);border-radius:18px;background:rgba(255,255,255,.08);backdrop-filter:blur(13px)}
.hero-fact strong{display:block;margin-bottom:3px;color:#fff;font-size:clamp(20px,3vw,30px);line-height:1.05;letter-spacing:-.03em}
.hero-fact span{display:block;color:rgba(255,255,255,.65);font-size:12px;line-height:1.35}
.hero-facts-note{max-width:760px;margin:10px 0 0;color:rgba(255,255,255,.52);font-size:11px;line-height:1.45}
.hero__business{display:inline-flex;align-items:center;gap:9px;margin-top:18px;color:#C8FFF9;font-size:14px;font-weight:720}
.hero__business i{width:8px;height:8px;border-radius:50%;background:var(--teal);box-shadow:0 0 0 7px rgba(0,196,180,.14)}
.stats-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(255px,82%);gap:12px;overflow-x:auto;padding:2px 16px 15px;margin-inline:-16px;scroll-snap-type:x mandatory;scrollbar-width:none}
.stats-grid::-webkit-scrollbar{display:none}
.stat-card{position:relative;min-height:185px;padding:24px;border:1px solid var(--line);border-radius:var(--radius-lg);background:#fff;overflow:hidden;box-shadow:0 16px 48px rgba(4,29,46,.06);scroll-snap-align:start}
.stat-card::after{content:"";position:absolute;right:-36px;bottom:-48px;width:150px;height:150px;border-radius:50%;background:radial-gradient(circle,rgba(0,196,180,.17),transparent 68%)}
.stat-card__value{display:block;margin-bottom:8px;color:var(--navy);font-size:clamp(34px,5vw,54px);font-weight:830;letter-spacing:-.06em;line-height:1}
.stat-card__label{max-width:240px;margin:0;color:var(--muted);font-size:14px;line-height:1.45}
.stat-card__source{position:absolute;left:24px;bottom:19px;color:#7E93A3;font-size:10px;text-transform:uppercase;letter-spacing:.09em}
.market-layout{display:grid;gap:24px}
.gap-card{position:relative;display:grid;gap:16px;padding:27px;border-radius:var(--radius-xl);background:linear-gradient(145deg,var(--navy),#0C3650);color:#fff;overflow:hidden;box-shadow:var(--shadow)}
.gap-card::after{content:"";position:absolute;inset:auto -160px -200px auto;width:480px;height:480px;border-radius:50%;background:radial-gradient(circle,rgba(0,196,180,.22),transparent 67%)}
.gap-card h3{font-size:clamp(27px,3.5vw,42px);max-width:600px}
.gap-columns{position:relative;z-index:1;display:grid;gap:12px}
.gap-column{padding:20px;border:1px solid rgba(255,255,255,.13);border-radius:20px;background:rgba(255,255,255,.07)}
.gap-column--accent{background:rgba(0,196,180,.13);border-color:rgba(0,196,180,.34)}
.gap-column__title{display:flex;align-items:center;gap:10px;margin-bottom:12px;font-weight:820}
.gap-column__title span{display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:rgba(255,255,255,.12);color:var(--teal-2);font-size:13px}
.plain-list{display:grid;gap:9px;margin:0;padding:0;list-style:none}
.plain-list li{position:relative;padding-left:19px;color:rgba(255,255,255,.72);font-size:14px}
.plain-list li::before{content:"";position:absolute;left:0;top:.65em;width:7px;height:7px;border-radius:50%;background:var(--teal)}
.promise-strip{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(255px,82%);gap:12px;overflow-x:auto;padding:2px 16px 15px;margin:0 -16px 24px;scroll-snap-type:x mandatory;scrollbar-width:none}
.promise-strip::-webkit-scrollbar{display:none}
.promise-card{padding:22px;border:1px solid var(--line);border-radius:20px;background:#fff;scroll-snap-align:start}
.promise-card__icon{display:grid;place-items:center;width:42px;height:42px;margin-bottom:14px;border-radius:13px;color:var(--navy);background:var(--ice)}
.promise-card__icon svg{width:22px;height:22px}
.promise-card h3{font-size:20px}
.promise-card p{margin:0;color:var(--muted);font-size:14px}
.system-panel{padding:clamp(24px,4vw,40px);border:1px solid var(--line);border-radius:var(--radius-xl);background:linear-gradient(145deg,#fff,#F2FBFA);box-shadow:0 22px 70px rgba(4,30,48,.08)}
.system-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(245px,80%);gap:10px;overflow-x:auto;padding:2px 1px 14px;scroll-snap-type:x mandatory;scrollbar-width:none}
.system-grid::-webkit-scrollbar{display:none}
.system-item{min-height:160px;padding:20px;border-radius:19px;background:#fff;border:1px solid rgba(10,37,64,.09);scroll-snap-align:start}
.system-item__num{display:inline-flex;margin-bottom:12px;color:#009D91;font-size:12px;font-weight:900;letter-spacing:.1em}
.system-item strong{display:block;margin-bottom:5px;color:var(--navy);font-size:17px}
.system-item span{display:block;color:var(--muted);font-size:13px;line-height:1.45}
.system-bottom{display:grid;gap:18px;align-items:center;margin-top:24px;padding-top:24px;border-top:1px solid var(--line)}
.system-bottom:only-child{margin-top:0;padding-top:0;border-top:0}
.system-bottom__statement{font-size:clamp(22px,3.3vw,34px);font-weight:800;line-height:1.16;letter-spacing:-.035em}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{padding:8px 12px;border-radius:999px;background:var(--ice);color:#176A67;font-size:12px;font-weight:720}
.equipment-shell{overflow:hidden}
.equipment-track{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(275px,84%);gap:14px;overflow-x:auto;padding:2px 16px 16px;margin-inline:-16px;scroll-snap-type:x mandatory;scrollbar-width:none}
.equipment-track::-webkit-scrollbar{display:none}
.equipment-card{position:relative;min-height:430px;display:flex;flex-direction:column;scroll-snap-align:start;border:1px solid rgba(255,255,255,.13);border-radius:26px;background:linear-gradient(155deg,rgba(255,255,255,.10),rgba(255,255,255,.045));overflow:hidden}
.equipment-card__media{position:relative;height:220px;display:grid;place-items:center;padding:20px;overflow:hidden;background:radial-gradient(circle at 50% 42%,rgba(0,196,180,.15),rgba(3,22,38,.16) 46%,rgba(3,22,38,.64) 100%)}
.equipment-card__media img{width:auto;height:auto;max-width:92%;max-height:92%;object-fit:contain;filter:drop-shadow(0 18px 24px rgba(0,0,0,.28))}
.equipment-card__body{flex:1;padding:22px;display:flex;flex-direction:column}
.equipment-card__kicker{margin-bottom:8px;color:var(--teal-2);font-size:11px;font-weight:850;letter-spacing:.12em;text-transform:uppercase}
.equipment-card h3{color:#fff;font-size:27px}
.equipment-card p{margin-bottom:18px;color:rgba(255,255,255,.66);font-size:14px}
.equipment-card__role{margin-top:auto;padding-top:15px;border-top:1px solid rgba(255,255,255,.12);color:#C7FFF9;font-size:13px;font-weight:720}
.equipment-note{display:flex;align-items:flex-start;gap:12px;margin-top:22px;padding:18px 20px;border:1px solid rgba(0,196,180,.26);border-radius:18px;background:rgba(0,196,180,.09);color:rgba(255,255,255,.76);font-size:13px}
.equipment-note svg{width:20px;height:20px;flex:0 0 auto;color:var(--teal-2)}
.programs-layout{display:grid;gap:24px;min-width:0}
.programs-layout>*{min-width:0}
.program-track{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(280px,86%);gap:12px;overflow-x:auto;padding:2px 16px 15px;margin-inline:-16px;scroll-snap-type:x mandatory;scrollbar-width:none}
.program-track::-webkit-scrollbar{display:none}
.program-card{min-height:230px;padding:23px;border:1px solid var(--line);border-radius:22px;background:#fff;scroll-snap-align:start;box-shadow:0 15px 45px rgba(4,30,48,.055)}
.program-card__num{display:block;margin-bottom:22px;color:#00A99C;font-size:13px;font-weight:900;letter-spacing:.12em}
.program-card h3{font-size:clamp(19px,1.75vw,23px);line-height:1.12;overflow-wrap:anywhere;hyphens:auto}
.program-card p{margin:0;color:var(--muted);font-size:14px;line-height:1.48;overflow-wrap:anywhere;hyphens:auto}
.patient-route{padding:clamp(24px,4vw,36px);border-radius:var(--radius-xl);background:var(--navy);color:#fff;box-shadow:var(--shadow)}
.route-steps{display:grid;gap:9px;margin-top:22px}
.route-step{display:grid;grid-template-columns:42px 1fr;gap:13px;align-items:center;padding:15px;border:1px solid rgba(255,255,255,.11);border-radius:17px;background:rgba(255,255,255,.06)}
.route-step__num{display:flex;align-items:center;justify-content:center;width:42px;height:42px;min-width:42px;border-radius:13px;background:var(--teal);color:var(--navy);font-weight:900;line-height:1;text-align:center;padding:0;font-variant-numeric:tabular-nums}
.route-step strong{display:block;margin-bottom:2px;font-size:16px;line-height:1.18;overflow-wrap:anywhere;hyphens:auto}
.route-step span{display:block;color:rgba(255,255,255,.63);font-size:13px;line-height:1.42;overflow-wrap:anywhere;hyphens:auto}
.formats-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(255px,82%);gap:12px;overflow-x:auto;padding:2px 16px 15px;margin-inline:-16px;scroll-snap-type:x mandatory;scrollbar-width:none}
.formats-grid::-webkit-scrollbar{display:none}
.format-card{position:relative;padding:24px;border:1px solid var(--line);border-radius:22px;background:#fff;overflow:hidden;scroll-snap-align:start}
.format-card::after{content:"";position:absolute;right:-30px;top:-30px;width:110px;height:110px;border-radius:50%;background:rgba(0,196,180,.08)}
.format-card__area{display:block;margin-bottom:9px;color:var(--navy);font-size:clamp(36px,5vw,52px);font-weight:850;letter-spacing:-.055em;line-height:1}
.format-card h3{font-size:20px}
.format-card p{margin:0;color:var(--muted);font-size:14px}
.format-note{margin-top:16px;color:var(--muted);font-size:13px}
.launch-layout{display:grid;gap:20px;margin-top:30px}
.launch-layout.launch-layout--single{grid-template-columns:1fr}
.timeline{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(250px,82%);gap:10px;overflow-x:auto;padding:2px 1px 14px;scroll-snap-type:x mandatory;scrollbar-width:none}
.timeline::-webkit-scrollbar{display:none}
.timeline-step{min-height:155px;padding:19px;border-radius:20px;background:var(--ice);border:1px solid rgba(0,196,180,.16);scroll-snap-align:start}
.timeline-step__week{display:block;margin-bottom:12px;color:#008F84;font-size:12px;font-weight:900;letter-spacing:.11em;text-transform:uppercase}
.timeline-step strong{display:block;margin-bottom:6px;color:var(--navy);font-size:18px}
.timeline-step span{display:block;color:var(--muted);font-size:13px}
.economics-card{position:relative;padding:clamp(25px,4vw,38px);border-radius:var(--radius-xl);background:linear-gradient(145deg,var(--navy),#0D3B56);color:#fff;overflow:hidden;box-shadow:var(--shadow)}
.economics-card::after{content:"";position:absolute;right:-130px;bottom:-150px;width:390px;height:390px;border-radius:50%;background:radial-gradient(circle,rgba(0,196,180,.25),transparent 66%)}
.economics-card__value{position:relative;z-index:1;display:block;margin-bottom:7px;color:var(--teal-2);font-size:clamp(50px,7vw,78px);font-weight:880;letter-spacing:-.065em;line-height:1}
.economics-card h3{position:relative;z-index:1;max-width:450px;font-size:clamp(25px,3vw,36px)}
.economics-card p{position:relative;z-index:1;max-width:560px;color:rgba(255,255,255,.68);font-size:14px}
.assumptions{position:relative;z-index:1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:20px}
.assumption{padding:13px;border-radius:14px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10)}
.assumption strong{display:block;color:#fff;font-size:16px}
.assumption span{display:block;color:rgba(255,255,255,.57);font-size:11px}
.why-layout{display:grid;gap:24px;align-items:stretch;min-width:0}
.why-layout>*{min-width:0}
.why-photo{position:relative;min-height:390px;border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow)}
.why-photo img{width:100%;height:100%;object-fit:cover}
.why-photo::after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(3,24,39,.68),transparent 56%)}
.why-photo__caption{position:absolute;z-index:1;left:23px;right:23px;bottom:21px;color:#fff}
.why-photo__caption strong{display:block;font-size:22px}
.why-photo__caption span{display:block;color:rgba(255,255,255,.67);font-size:13px}
.why-list{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(280px,84%);gap:10px;width:calc(100% + 32px);max-width:calc(100% + 32px);overflow-x:auto;padding:2px 16px 14px;margin-inline:-16px;scroll-snap-type:x mandatory;scrollbar-width:none}
.why-list::-webkit-scrollbar{display:none}
.why-item{display:grid;grid-template-columns:44px 1fr;gap:14px;padding:18px;border:1px solid var(--line);border-radius:19px;background:#fff;scroll-snap-align:start}
.why-item__icon{display:grid;place-items:center;width:44px;height:44px;border-radius:14px;background:var(--ice);color:#008F84}
.why-item__icon svg{width:22px;height:22px}
.why-item strong{display:block;margin-bottom:4px;color:var(--navy);font-size:16px}
.why-item span{display:block;color:var(--muted);font-size:13px}
.company-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:18px}
.company-stat{padding:18px;border:1px solid var(--line);border-radius:18px;background:#fff}
.company-stat strong{display:block;color:var(--navy);font-size:30px;line-height:1;letter-spacing:-.045em}
.company-stat span{display:block;margin-top:6px;color:var(--muted);font-size:12px}
.cta-section{position:relative;padding:clamp(72px,9vw,120px) 0;overflow:hidden;background:var(--navy-2);color:#fff}
.cta-section::before{content:"";position:absolute;inset:0;background:radial-gradient(circle at 20% 20%,rgba(0,196,180,.18),transparent 33%),radial-gradient(circle at 90% 100%,rgba(37,117,151,.26),transparent 39%)}
.cta-layout{position:relative;display:grid;gap:30px}
.cta-copy h2{font-size:clamp(39px,6vw,68px)}
.cta-copy .lead{color:rgba(255,255,255,.72)}
.cta-checks{display:grid;gap:10px;margin:25px 0 0;padding:0;list-style:none}
.cta-checks li{display:flex;gap:10px;color:rgba(255,255,255,.75);font-size:14px}
.cta-checks svg{width:20px;height:20px;flex:0 0 auto;color:var(--teal-2)}
.form-card{padding:clamp(23px,4vw,34px);border:1px solid rgba(255,255,255,.14);border-radius:var(--radius-xl);background:rgba(255,255,255,.10);backdrop-filter:blur(20px);box-shadow:0 30px 90px rgba(0,0,0,.23)}
.form-grid{display:grid;gap:12px}
.field{display:grid;gap:7px}
.field label{font-size:12px;font-weight:750;color:rgba(255,255,255,.70)}
.field input,.field textarea,.field select{width:100%;min-height:52px;padding:0 15px;border:1px solid rgba(255,255,255,.18);border-radius:14px;outline:none;color:#fff;background:rgba(255,255,255,.08);transition:border-color .2s ease,background .2s ease,box-shadow .2s ease}
.field textarea{min-height:104px;padding-top:14px;resize:vertical}
.field select{appearance:none;background-image:linear-gradient(45deg,transparent 50%,rgba(255,255,255,.7) 50%),linear-gradient(135deg,rgba(255,255,255,.7) 50%,transparent 50%);background-position:calc(100% - 18px) 22px,calc(100% - 13px) 22px;background-size:5px 5px;background-repeat:no-repeat}
.field select option{color:var(--navy);background:#fff}
.field input::placeholder,.field textarea::placeholder{color:rgba(255,255,255,.42)}
.field input:focus,.field textarea:focus,.field select:focus{border-color:var(--teal);background:rgba(255,255,255,.12);box-shadow:0 0 0 4px rgba(0,196,180,.12)}
.field--full{grid-column:1/-1}
.form-card .btn{width:100%;margin-top:4px}
.form-consent{margin:10px 0 0;color:rgba(255,255,255,.45);font-size:11px}
.form-status{display:none;margin-top:12px;padding:13px 15px;border-radius:13px;font-size:13px}
.form-status.show{display:block}
.form-status.success{background:rgba(0,196,180,.15);color:#C8FFF9;border:1px solid rgba(0,196,180,.30)}
.form-status.error{background:rgba(255,96,96,.12);color:#FFD6D6;border:1px solid rgba(255,96,96,.28)}
.hp{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important}
.footer{padding:26px 0;background:#041524;color:rgba(255,255,255,.52);font-size:12px}
.footer__inner{display:grid;gap:14px}
.footer__brand img{width:185px}
.footer__legal{max-width:900px;line-height:1.55}
.footer__bottom{display:flex;flex-wrap:wrap;justify-content:space-between;gap:10px;padding-top:13px;border-top:1px solid rgba(255,255,255,.09)}
[data-reveal]{opacity:0;transform:translateY(24px);transition:opacity .7s ease,transform .7s cubic-bezier(.2,.65,.3,1)}
[data-reveal].is-visible{opacity:1;transform:none}
.scroll-hint{display:flex;align-items:center;gap:8px;margin-top:12px;color:var(--muted);font-size:11px}
.section--dark .scroll-hint{color:rgba(255,255,255,.48)}
.scroll-hint::before{content:"";width:24px;height:1px;background:currentColor}
@media(min-width:600px){
.hero__actions{flex-direction:row;align-items:center}
.stats-grid{grid-auto-flow:initial;grid-auto-columns:auto;grid-template-columns:repeat(2,minmax(0,1fr));overflow:visible;padding:0;margin:0}
.stat-card{min-height:205px}
.promise-strip{grid-auto-flow:initial;grid-auto-columns:auto;grid-template-columns:repeat(2,minmax(0,1fr));overflow:visible;padding:0;margin:0 0 28px}
.program-track{grid-auto-columns:minmax(300px,48%)}
.formats-grid{grid-auto-flow:initial;grid-auto-columns:auto;grid-template-columns:repeat(3,minmax(0,1fr));overflow:visible;padding:0;margin:0}
.company-stats{grid-template-columns:repeat(4,minmax(0,1fr))}
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(min-width:760px){
.container{width:min(calc(100% - 48px),var(--container))}
.mobile-hidden{display:inline-flex}
.section-head--split{grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr);align-items:end}
.market-layout{grid-template-columns:minmax(0,1.08fr) minmax(360px,.92fr);align-items:stretch}
.gap-columns{grid-template-columns:repeat(2,minmax(0,1fr))}
.promise-strip{grid-template-columns:repeat(4,minmax(0,1fr))}
.system-grid{grid-auto-flow:initial;grid-auto-columns:auto;grid-template-columns:repeat(3,minmax(0,1fr));overflow:visible;padding:0}
.system-bottom{grid-template-columns:minmax(0,1fr) minmax(340px,.85fr)}
.equipment-track{grid-auto-flow:initial;grid-template-columns:repeat(3,minmax(0,1fr));overflow:visible;padding:0;margin:0}
.equipment-card{min-height:425px}
.programs-layout{display:block}
.program-track{grid-auto-flow:initial;grid-template-columns:repeat(5,minmax(0,1fr));overflow:visible;padding:0;margin:0}
.program-card{min-height:255px}
.program-card:last-child{grid-column:auto}
.patient-route{margin-top:18px}
.route-steps{grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}
.route-step{display:block;min-height:170px;padding:14px}
.route-step__num{margin-bottom:12px}
.timeline{grid-auto-flow:initial;grid-auto-columns:auto;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));align-self:stretch;overflow:visible;padding:0}
.timeline-step{min-height:225px}
.launch-layout{grid-template-columns:minmax(0,1.28fr) minmax(360px,.72fr);align-items:stretch}
.why-layout{grid-template-columns:minmax(360px,.9fr) minmax(0,1.1fr)}
.why-list{grid-auto-flow:initial;grid-auto-columns:auto;width:auto;max-width:none;overflow:visible;padding:0;margin:0}
.cta-layout{grid-template-columns:minmax(0,1fr) minmax(430px,.83fr);align-items:center}
.footer__inner{grid-template-columns:220px 1fr}
.footer__bottom{grid-column:1/-1}
}
@media(min-width:980px){
.header__nav{display:flex}
.hero{min-height:810px}
.hero::before{background-position:72% center}
.hero::after{background:linear-gradient(90deg,rgba(4,24,41,.99) 0%,rgba(5,28,47,.93) 42%,rgba(5,28,47,.30) 74%,rgba(5,28,47,.10) 100%),linear-gradient(0deg,rgba(3,20,34,.75),transparent 50%)}
.equipment-track{grid-template-columns:repeat(3,minmax(0,1fr))}
.equipment-card__media{height:220px}
}
@media(max-width:520px){
.header .btn--header{padding-inline:14px;font-size:12px}
.brand img{width:155px}
.hero__facts{grid-template-columns:1fr 1fr}
.hero-fact:last-child{grid-column:1/-1}
.stat-card{min-height:172px;padding:20px}
.stat-card__source{left:20px;bottom:15px}
.system-grid{grid-template-columns:none}
.assumptions{grid-template-columns:1fr 1fr}
}
@media(prefers-reduced-motion:reduce){
html{scroll-behavior:auto}
*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}
[data-reveal]{opacity:1;transform:none}
}
.trend-block{position:relative;z-index:1;margin-top:4px;padding-top:20px;border-top:1px solid rgba(255,255,255,.13)}
.trend-title{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:18px}
.trend-title strong{font-size:18px;line-height:1.2}
.trend-title span{color:rgba(255,255,255,.58);font-size:12px;text-align:right}
.trend-chart{height:196px;display:flex;align-items:flex-end;gap:10px;padding:14px 10px 0;border-left:1px solid rgba(255,255,255,.15);border-bottom:1px solid rgba(255,255,255,.15)}
.trend-col{height:100%;min-width:0;flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:8px}
.trend-value{color:#fff;font-size:12px;font-weight:800;white-space:nowrap}
.trend-bar{width:min(62px,72%);height:var(--h);min-height:28px;border-radius:14px 14px 3px 3px;background:linear-gradient(180deg,var(--teal-2),var(--teal));box-shadow:0 10px 24px rgba(0,196,180,.2);transform:scaleY(0);transform-origin:bottom;transition:transform .8s cubic-bezier(.2,.8,.2,1)}
[data-reveal].is-visible .trend-bar{transform:scaleY(1)}
.trend-year{color:rgba(255,255,255,.64);font-size:12px;font-weight:760}
.source-links a{color:inherit;text-decoration:underline;text-decoration-color:rgba(10,37,64,.25);text-underline-offset:3px}
.section--dark .source-links a,.gap-card .source-links a{color:inherit;text-decoration-color:rgba(255,255,255,.32)}
.model-label{display:inline-flex;align-items:center;gap:8px;margin-bottom:10px;color:#008f84;font-size:11px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}
.model-label::before{content:"";width:18px;height:2px;background:var(--teal);border-radius:2px}
.comparison-kicker{display:block;margin-bottom:6px;color:var(--teal-2);font-size:12px;font-weight:850;letter-spacing:.1em;text-transform:uppercase}
@media (max-width:520px){
.trend-chart{height:174px;gap:6px;padding-inline:6px}
.trend-value{font-size:11px}
.trend-title{align-items:flex-start;flex-direction:column;gap:4px}
.trend-title span{text-align:left}
}
/* Unified corporate logo and resident status block */
.exo-master-logo {
display:block !important;
height:auto !important;
object-fit:contain;
object-position:left center;
flex:0 0 auto;
}
.brand .exo-master-logo--header,
.header .exo-master-logo--header,
.site-header .exo-master-logo--header {
width:clamp(172px,20vw,232px) !important;
max-width:100%;
}
.footer .exo-master-logo--footer {
width:clamp(150px,16vw,180px) !important;
max-width:min(50vw,180px) !important;
}
.footer__brand,
.footer-brand-logo {
display:flex;
align-items:center;
min-height:0 !important;
flex:0 0 auto;
max-width:180px;
}
.footer__brand img,
.footer-brand-logo img {display:block}
.footer__legal,
.footer-links,
.footer__right {
min-width:0;
position:relative;
z-index:1;
}
@media(max-width:599px){
.footer .exo-master-logo--footer {
width:150px !important;
max-width:48vw !important;
}
}
.exo-residency {
position:relative;
isolation:isolate;
overflow:hidden;
padding:34px 0 16px;
color:#fff;
background:linear-gradient(180deg,#071d31 0%,#041524 100%);
border-top:1px solid rgba(255,255,255,.08);
}
.exo-residency::before {
content:"";
position:absolute;
z-index:-1;
width:360px;
height:360px;
right:-120px;
top:-200px;
border-radius:50%;
background:rgba(0,196,180,.16);
filter:blur(80px);
pointer-events:none;
}
.exo-residency__inner {
display:flex;
flex-direction:column;
gap:20px;
}
.exo-residency__copy {
display:grid;
gap:6px;
max-width:580px;
}
.exo-residency__eyebrow {
color:#00c4b4;
font-size:10px;
line-height:1.2;
font-weight:850;
letter-spacing:.16em;
}
.exo-residency__copy strong {
color:#fff;
font-size:clamp(18px,2.6vw,27px);
line-height:1.15;
letter-spacing:-.02em;
}
.exo-residency__logos {
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:11px;
width:min(100%,540px);
}
.exo-resident-card {
min-width:0;
min-height:82px;
padding:12px 16px;
display:flex;
align-items:center;
justify-content:center;
border-radius:18px;
background:rgba(255,255,255,.97);
border:1px solid rgba(255,255,255,.75);
box-shadow:0 16px 40px rgba(0,0,0,.13);
transition:transform .22s ease,box-shadow .22s ease,border-color .22s ease;
}
.exo-resident-card:hover {
transform:translateY(-3px);
box-shadow:0 20px 46px rgba(0,0,0,.20);
border-color:rgba(0,196,180,.72);
}
.exo-resident-card__logo {
display:block !important;
width:auto !important;
max-width:100% !important;
height:auto !important;
object-fit:contain;
}
.exo-resident-card__logo--zhiguli {max-height:58px !important}
.exo-resident-card__logo--skolkovo {max-height:58px !important}
@media (min-width:760px) {
.exo-residency {padding:42px 0 20px}
.exo-residency__inner {
flex-direction:row;
align-items:center;
justify-content:space-between;
gap:34px;
}
.exo-residency__logos {flex:0 0 min(50%,540px)}
}
@media (max-width:560px) {
.brand .exo-master-logo--header,
.header .exo-master-logo--header,
.site-header .exo-master-logo--header {width:168px !important}
.exo-residency__logos {grid-template-columns:1fr}
.exo-resident-card {min-height:78px}
.exo-resident-card__logo--zhiguli,
.exo-resident-card__logo--skolkovo {max-height:54px !important}
}
/* final corrections */
.hero::before{background-position:84% center !important;transform:none !important}
.hero__content{position:relative;z-index:2}
/* final custom updates */
.header{position:fixed !important;top:0;left:0;right:0;background:rgba(6,27,47,.92) !important;box-shadow:0 12px 42px rgba(3,20,34,.18);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px)}
.header__actions{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}
.btn--call{color:#fff;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.22);backdrop-filter:blur(10px)}
.btn--call:hover{background:rgba(255,255,255,.14);border-color:rgba(255,255,255,.38)}
.btn--call svg{width:16px;height:16px}
.hero::before{background-position:78% center !important;background-size:cover !important}
.hero__facts{grid-template-columns:1fr !important;max-width:780px}
.hero-fact strong{overflow-wrap:anywhere}
.equipment-card{min-height:488px}
.equipment-card__media{height:272px;padding:26px 18px;overflow:visible}
.equipment-card__media img{display:block;max-width:100% !important;max-height:100% !important;width:auto !important;height:auto !important;object-fit:contain !important;object-position:center center;filter:drop-shadow(0 18px 24px rgba(0,0,0,.28))}
.equipment-card p{margin-bottom:16px;color:rgba(255,255,255,.72);font-size:13px;line-height:1.55}
.equipment-card__role{font-size:13px;line-height:1.55}
.timeline-title{margin:0 0 18px;color:var(--navy);font-size:clamp(24px,3vw,34px);font-weight:850;letter-spacing:-.03em}
.timeline{grid-auto-columns:minmax(320px,90%) !important;gap:14px}
.timeline-step{min-height:auto;padding:22px 20px}
.timeline-step__week{margin-bottom:8px}
.timeline-step strong{margin-bottom:10px;line-height:1.2}
.timeline-step__lead{display:block;margin-bottom:14px;color:var(--muted);font-size:13px;line-height:1.55;font-style:italic}
.timeline-step ul{margin:0;padding-left:18px;display:grid;gap:8px;color:var(--muted);font-size:13px;line-height:1.55}
.timeline-step li{margin:0}
.timeline-step li strong{display:inline;margin:0;color:var(--navy);font-size:13px}
@media(min-width:600px){
.hero__facts{grid-template-columns:repeat(3,minmax(0,1fr)) !important}
}
@media(min-width:900px){
.timeline{grid-auto-flow:initial;grid-auto-columns:auto;grid-template-columns:repeat(2,minmax(0,1fr));overflow:visible;padding:0}
}
/* scaling fix for equipment photos */
.equipment-card__media{height:290px !important;padding:30px 22px !important;overflow:hidden !important}
.equipment-card__media img{max-width:82% !important;max-height:82% !important;object-fit:contain !important;object-position:center center !important;transform:scale(.9);transform-origin:center center}
.equipment-card--impulse .equipment-card__media img{max-width:78% !important;max-height:78% !important;transform:scale(.84)}
/* v5 equipment framing fix */
.equipment-card__media{height:300px !important;padding:18px 18px 8px !important;display:flex !important;align-items:flex-start !important;justify-content:center !important;overflow:hidden !important}
.equipment-card__media img{max-width:78% !important;max-height:78% !important;width:auto !important;height:auto !important;object-fit:contain !important;object-position:center top !important;transform:translateY(-26px) scale(.78) !important;transform-origin:top center !important}
.equipment-card--impulse .equipment-card__media img{max-width:70% !important;max-height:76% !important;transform:translateY(-8px) scale(.76) !important}
/* v6 lower product images */
.equipment-card__media{height:300px !important;padding:22px 18px 10px !important;display:flex !important;align-items:flex-start !important;justify-content:center !important;overflow:hidden !important}
.equipment-card__media img{max-width:72% !important;max-height:72% !important;width:auto !important;height:auto !important;object-fit:contain !important;object-position:center top !important;transform:translateY(12px) scale(.72) !important;transform-origin:top center !important}
.equipment-card--impulse .equipment-card__media img{max-width:78% !important;max-height:78% !important;transform:translateY(16px) scale(.72) !important}
/* v7 hero background shift for full logo visibility */
.hero::before{background-position:100% center !important;background-size:cover !important}
@media(max-width:980px){.hero::before{background-position:96% center !important}}
@media(max-width:640px){.hero::before{background-position:94% center !important}}
/* v8 enlarge product photos within frame */
.equipment-card__media{height:300px !important;padding:18px 18px 14px !important;display:flex !important;align-items:center !important;justify-content:center !important;overflow:hidden !important}
.equipment-card__media img{max-width:86% !important;max-height:86% !important;width:auto !important;height:auto !important;object-fit:contain !important;object-position:center center !important;transform:translateY(8px) !important;transform-origin:center center !important}
.equipment-card--impulse .equipment-card__media img{max-width:88% !important;max-height:88% !important;transform:translateY(10px) !important}
/* Laser B transparent product image fit */
.equipment-card--laser .equipment-card__media{overflow:hidden !important;display:flex !important;align-items:center !important;justify-content:center !important;padding:20px !important}
.equipment-card--laser .equipment-card__media img{width:auto !important;height:auto !important;max-width:82% !important;max-height:82% !important;object-fit:contain !important;object-position:center center !important;transform:none !important;filter:drop-shadow(0 18px 24px rgba(0,0,0,.28)) !important}
@media(max-width:720px){.equipment-card--laser .equipment-card__media img{max-width:86% !important;max-height:86% !important}}
/* financial model blocks added from approved EXO materials */
.fin-model{padding:clamp(68px,8vw,110px) 0;background:#f5fafb;color:#0A2540}
.fin-model--dark{background:#071f35;color:#fff}
.fin-model .container{width:min(calc(100% - 32px),1200px);margin-inline:auto}
.fin-kicker{display:flex;align-items:center;gap:10px;margin:0 0 14px;color:#00a99a;font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
.fin-kicker:before{content:"";width:26px;height:2px;background:#00C4B4;border-radius:2px}
.fin-model--dark .fin-kicker{color:#39E0D2}
.fin-title{max-width:980px;margin:0 0 10px;font-size:clamp(32px,4.8vw,56px);line-height:1.04;letter-spacing:-.04em}
.fin-lead{max-width:900px;margin:0 0 34px;color:#60778B;font-size:clamp(17px,1.8vw,21px);line-height:1.55}
.fin-model--dark .fin-lead{color:rgba(255,255,255,.72)}
.fin-formula{display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center;padding:20px 24px;border:1px solid rgba(0,196,180,.28);border-radius:18px;background:rgba(0,196,180,.06);margin:0 0 28px}
.fin-model--dark .fin-formula{background:rgba(255,255,255,.035);border-color:rgba(0,196,180,.28)}
.fin-formula__tag{color:#00a99a;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}
.fin-formula__main{font-weight:800;font-size:clamp(18px,2vw,24px)}
.fin-formula__note{font-size:12px;color:#60778B;text-align:right}
.fin-model--dark .fin-formula__note{color:rgba(255,255,255,.55)}
.fin-formula--course-note{grid-template-columns:170px 1fr;align-items:start;gap:22px}
.fin-formula__copy{font-size:15px;line-height:1.55;color:rgba(255,255,255,.78)}
.fin-formula__copy p{margin:0}
.fin-formula__copy p+p{margin-top:6px}
.fin-formula__copy strong{color:#fff;font-weight:800}
@media(max-width:760px){.fin-formula--course-note{grid-template-columns:1fr;gap:10px}.fin-formula__copy{font-size:14px}}
.fin-scenarios{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:0 0 36px}
.fin-scenario{padding:26px 24px;border-radius:24px;border:1px solid rgba(0,196,180,.26);background:#0e3b5b;color:white;min-height:190px;display:flex;flex-direction:column;justify-content:space-between}
.fin-scenario strong{display:block;color:#39E0D2;font-size:clamp(34px,4vw,48px);line-height:1}
.fin-scenario small{color:rgba(255,255,255,.58)}
.fin-scenario b{font-size:clamp(24px,3vw,36px)}
.fin-drivers-title{margin:0 0 16px;font-size:18px}
.fin-drivers{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.fin-driver{padding:18px 14px;border:1px solid rgba(0,196,180,.48);border-radius:14px;text-align:center;font-weight:700;font-size:14px}
.fin-note{margin:28px 0 0;padding:18px 20px;border-radius:14px;border:1px solid rgba(0,196,180,.2);color:#60778B;background:rgba(0,196,180,.04);font-size:13px;line-height:1.5}
.fin-model--dark .fin-note{color:rgba(255,255,255,.58);background:rgba(255,255,255,.035)}
.course-table-wrap{overflow-x:auto;border:1px solid rgba(10,37,64,.1);border-radius:18px;background:#fff;box-shadow:0 18px 55px rgba(3,26,43,.08)}
.course-table{width:100%;border-collapse:collapse;min-width:860px}
.course-table th{padding:16px;text-align:left;background:#00C4B4;color:#063047;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
.course-table td{padding:18px 16px;border-bottom:1px solid rgba(10,37,64,.09);vertical-align:top;font-size:14px;line-height:1.45}
.course-table tr:nth-child(even) td{background:#eefaf8}
.course-table tr:last-child td{border-bottom:0}
.course-table td:first-child,.course-table td:last-child{font-weight:800}
.avg-course{margin-top:26px;padding:24px;border:1px solid #00C4B4;border-radius:16px;display:grid;grid-template-columns:260px 1fr;gap:24px;align-items:center;background:#f1fbf9}
.avg-course span{font-size:13px;color:#60778B}.avg-course strong{display:block;margin-top:5px;font-size:clamp(30px,4vw,44px)}
@media(max-width:760px){.fin-formula{grid-template-columns:1fr;gap:8px}.fin-formula__note{text-align:left}.fin-scenarios{grid-template-columns:1fr}.fin-drivers{grid-template-columns:1fr 1fr}.avg-course{grid-template-columns:1fr}.course-table-wrap{margin-right:-16px;border-radius:18px 0 0 18px}}
/* Financial leakage block: existing physiotherapy cabinet */
.loss-economy{overflow:hidden;background:linear-gradient(180deg,#f8fcfd 0%,#eef8f8 100%)}
.loss-economy::before{content:"";position:absolute;inset:-220px auto auto 52%;width:560px;height:560px;border-radius:50%;background:radial-gradient(circle,rgba(0,196,180,.13),rgba(0,196,180,0) 68%);pointer-events:none}
.loss-economy__head{position:relative;z-index:1;max-width:960px;margin-bottom:clamp(32px,4.5vw,54px)}
.loss-economy__head h2{margin-bottom:14px;font-size:clamp(40px,5.7vw,68px)}
.loss-economy__head .lead{max-width:880px}
.loss-economy__grid{position:relative;z-index:1;display:grid;grid-template-columns:minmax(0,1.03fr) minmax(0,1fr);gap:24px;align-items:stretch}
.loss-economy__panel{border-radius:30px;overflow:hidden;box-shadow:0 28px 80px rgba(4,30,48,.12)}
.loss-economy__panel--loss{padding:30px;background:linear-gradient(145deg,#08253f 0%,#061b2f 100%);color:#fff;border:1px solid rgba(69,228,215,.18)}
.loss-economy__panel--gain{padding:30px;background:rgba(255,255,255,.88);border:1px solid rgba(0,196,180,.28);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
.loss-economy__panel-title{display:flex;align-items:center;gap:10px;margin:0 0 22px;font-size:13px;font-weight:850;line-height:1.25;letter-spacing:.09em;text-transform:uppercase}
.loss-economy__panel--loss .loss-economy__panel-title{color:#fff}
.loss-economy__panel--gain .loss-economy__panel-title{color:var(--navy)}
.loss-economy__panel-title svg{width:22px;height:22px;flex:0 0 auto;color:var(--teal)}
.loss-flow{display:grid;grid-template-columns:102px minmax(0,1fr) 102px;gap:14px;align-items:end}
.loss-flow__anchor{min-height:118px;padding:15px 10px;border-radius:17px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,rgba(7,58,83,.98),rgba(5,35,58,.98));border:1px solid rgba(69,228,215,.48);box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}
.loss-flow__anchor svg{width:28px;height:28px;margin-bottom:5px;color:var(--teal-2)}
.loss-flow__anchor strong{display:block;color:var(--teal-2);font-size:25px;line-height:1;font-weight:850}
.loss-flow__anchor span{display:block;margin-top:7px;color:rgba(255,255,255,.68);font-size:10px;line-height:1.3}
.loss-flow__stages{position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;min-width:0}
.loss-flow__stages::before{content:"";position:absolute;z-index:0;left:5%;right:5%;top:99px;height:13px;border-radius:999px;background:linear-gradient(180deg,#eff8fa 0%,#9fbbc4 45%,#dcecef 55%,#547986 100%);box-shadow:0 0 15px rgba(69,228,215,.25),inset 0 1px 1px rgba(255,255,255,.8)}
.loss-stage{position:relative;z-index:1;min-width:0;text-align:center}
.loss-stage__label{min-height:42px;display:flex;align-items:flex-end;justify-content:center;color:rgba(255,255,255,.88);font-size:10px;line-height:1.2}
.loss-stage__percent{display:block;margin:5px 0 7px;color:var(--teal-2);font-size:20px;line-height:1;font-weight:850}
.loss-stage__hole{position:relative;width:31px;height:31px;margin:0 auto 30px;border-radius:50%;background:radial-gradient(circle at 50% 42%,#061b2f 0 34%,#5f7e88 36% 48%,#d9e9eb 50% 65%,#6c8c96 67% 100%);box-shadow:0 0 12px rgba(69,228,215,.32)}
.loss-stage__hole::after{content:"";position:absolute;left:50%;top:36px;width:9px;height:14px;transform:translateX(-50%) rotate(45deg);border-radius:65% 35% 65% 35%;background:linear-gradient(135deg,#e9fbff,#6da7b7);box-shadow:0 5px 12px rgba(0,196,180,.22)}
.loss-economy__loss-summary{display:grid;grid-template-columns:minmax(180px,.8fr) minmax(0,1.2fr);gap:14px;margin-top:24px;padding-top:20px;border-top:1px solid rgba(255,255,255,.12)}
.loss-total{padding:18px;border-radius:18px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.10)}
.loss-total span{display:block;color:rgba(255,255,255,.55);font-size:10px;line-height:1.3;text-transform:uppercase;letter-spacing:.04em}
.loss-total strong{display:block;margin:7px 0 5px;color:#fff;font-size:clamp(18px,1.95vw,26px);line-height:1.02;letter-spacing:-.04em;white-space:nowrap;font-variant-numeric:tabular-nums}
.loss-total small{display:block;color:rgba(255,255,255,.62);font-size:11px;line-height:1.4}
.loss-breakdown{display:grid;gap:8px;align-content:center}
.loss-breakdown__row{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;padding-bottom:7px;border-bottom:1px solid rgba(255,255,255,.09);font-size:10px;line-height:1.25}
.loss-breakdown__row:last-child{border-bottom:0;padding-bottom:0}
.loss-breakdown__row span{color:rgba(255,255,255,.68)}
.loss-breakdown__row strong{color:#fff;font-size:12px;white-space:nowrap}
.loss-solutions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}
.loss-solution{min-height:112px;padding:16px;border-radius:17px;border:1px solid rgba(10,37,64,.11);background:linear-gradient(180deg,#fff,#f5fbfb);display:grid;grid-template-columns:38px 1fr;gap:11px;align-items:start}
.loss-solution__icon{width:38px;height:38px;border-radius:13px;display:grid;place-items:center;background:var(--ice);color:#008f84}
.loss-solution__icon svg{width:21px;height:21px}
.loss-solution strong{display:block;margin-bottom:4px;color:var(--navy);font-size:14px;line-height:1.2}
.loss-solution span{display:block;color:var(--muted);font-size:11px;line-height:1.38}
.loss-result-title{margin:22px 0 12px;color:#008f84;font-size:11px;font-weight:850;letter-spacing:.06em;text-transform:uppercase}
.loss-formula{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr) auto minmax(0,.8fr) auto minmax(0,1.35fr);gap:7px;align-items:stretch}
.loss-formula__cell{min-width:0;padding:13px 8px;border-radius:15px;border:1px solid rgba(0,196,180,.28);background:#fff;text-align:center;display:flex;flex-direction:column;justify-content:center}
.loss-formula__cell strong{display:block;color:var(--navy);font-size:clamp(16px,1.9vw,22px);line-height:1.05;letter-spacing:-.03em;white-space:nowrap;font-variant-numeric:tabular-nums}
.loss-formula__cell span{display:block;margin-top:5px;color:var(--muted);font-size:9px;line-height:1.25}
.loss-formula__cell--result{background:linear-gradient(135deg,#00b9aa,#00cfc0);border-color:transparent;box-shadow:0 13px 30px rgba(0,196,180,.22)}
.loss-formula__cell--result strong,.loss-formula__cell--result span{color:#062b3d}
.loss-formula__op{align-self:center;color:#008f84;font-size:21px;font-weight:800}
.loss-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:13px;padding:14px;border-radius:17px;background:#effaf9;border:1px solid rgba(0,196,180,.22)}
.loss-metric{min-width:0;padding-right:8px;border-right:1px solid rgba(10,37,64,.10)}
.loss-metric:last-child{padding-right:0;border-right:0}
.loss-metric strong{display:block;color:#00a99a;font-size:14px;line-height:1.15;white-space:nowrap;font-variant-numeric:tabular-nums}
.loss-metric span{display:block;margin-top:4px;color:var(--muted);font-size:8.5px;line-height:1.3}
.loss-economy__closing{position:relative;z-index:1;margin-top:22px;padding:20px 24px;border:1px solid rgba(0,196,180,.22);border-radius:22px;background:rgba(255,255,255,.9);box-shadow:0 18px 55px rgba(4,30,48,.08);display:grid;grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:24px;align-items:center}
.loss-closing__item{display:grid;grid-template-columns:44px 1fr;gap:14px;align-items:center;min-width:0}
.loss-closing__item:first-child{padding-right:24px;border-right:1px solid rgba(10,37,64,.12)}
.loss-closing__icon{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--teal);color:#008f84;background:var(--ice)}
.loss-closing__icon svg{width:23px;height:23px}
.loss-closing__item p{margin:0;color:var(--navy);font-size:14px;line-height:1.45}
.loss-closing__item p + p{margin-top:3px}
.loss-closing__item strong{font-weight:850}
.loss-economy__note{position:relative;z-index:1;margin:14px 0 0;color:var(--muted);font-size:11px;line-height:1.5}
.loss-formula__cell--result strong{font-size:clamp(16px,1.35vw,20px)}
@media(max-width:1050px){
.loss-economy__grid{grid-template-columns:1fr}
.loss-flow__stages::before{top:93px}
.loss-formula__cell strong{font-size:24px}
}
@media(max-width:760px){
.loss-economy__panel--loss,.loss-economy__panel--gain{padding:22px 18px;border-radius:24px}
.loss-flow{grid-template-columns:1fr;align-items:stretch}
.loss-flow__anchor{min-height:0;display:grid;grid-template-columns:34px auto 1fr;gap:10px;text-align:left;justify-content:start}
.loss-flow__anchor svg{margin:0}
.loss-flow__anchor strong{font-size:22px}
.loss-flow__anchor span{margin:0}
.loss-flow__stages{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
.loss-flow__stages::before{display:none}
.loss-stage{padding:13px 10px;border-radius:15px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.10)}
.loss-stage__label{min-height:0;align-items:flex-start;font-size:11px}
.loss-stage__hole{display:none}
.loss-stage__percent{margin:8px 0 0}
.loss-economy__loss-summary{grid-template-columns:1fr}
.loss-solutions{grid-template-columns:1fr}
.loss-formula{grid-template-columns:1fr;gap:7px}
.loss-formula__op{justify-self:center;line-height:1;transform:rotate(90deg)}
.loss-formula__cell{padding:15px 12px}
.loss-metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.loss-metric{padding:0 0 10px;border-right:0;border-bottom:1px solid rgba(10,37,64,.10)}
.loss-metric:nth-last-child(-n+2){padding-bottom:0;border-bottom:0}
.loss-economy__closing{grid-template-columns:1fr;padding:18px}
.loss-closing__item:first-child{padding:0 0 18px;border-right:0;border-bottom:1px solid rgba(10,37,64,.12)}
}
@media(max-width:430px){
.loss-flow__stages{grid-template-columns:1fr}
.loss-economy__panel-title{font-size:11px}
.loss-economy__closing{border-radius:18px}
}
/* Revenue scenarios CTA */
.fin-revenue-cta{
margin-top:24px;
padding:18px 20px;
border:1px solid rgba(0,196,180,.24);
border-radius:18px;
background:linear-gradient(135deg,rgba(0,196,180,.10),rgba(255,255,255,.035));
display:flex;
align-items:center;
justify-content:space-between;
gap:20px;
}
.fin-revenue-cta span{
max-width:560px;
color:rgba(255,255,255,.62);
font-size:13px;
line-height:1.5;
}
.fin-revenue-btn{flex:0 0 auto;min-width:250px}
@media(max-width:700px){
.fin-revenue-cta{align-items:stretch;flex-direction:column}
.fin-revenue-btn{width:100%;min-width:0}
.fin-revenue-cta span{text-align:center}
}
/* Compact revenue calculator — existing physiotherapy cabinet */
.fin-calculator{margin:0 0 30px;padding:clamp(18px,2.2vw,26px);border:1px solid rgba(57,224,210,.34);border-radius:22px;background:linear-gradient(135deg,rgba(0,196,180,.08),rgba(255,255,255,.03));box-shadow:0 18px 46px rgba(0,0,0,.14)}
.fin-calculator__head{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.72fr);gap:18px;align-items:end;margin-bottom:17px}
.fin-calculator__eyebrow{display:block;margin:0 0 6px;color:#39E0D2;font-size:10px;font-weight:850;letter-spacing:.12em;text-transform:uppercase}
.fin-calculator__head h3{margin:0;color:#fff;font-size:clamp(20px,2.15vw,28px);line-height:1.1;letter-spacing:-.025em}
.fin-calculator__head p{max-width:430px;margin:0;color:rgba(255,255,255,.61);font-size:13px;line-height:1.45;text-align:right}
.fin-calculator__fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:12px}
.fin-calculator__results{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
.fin-calculator__field,.fin-calculator__result{min-width:0;padding:14px;border:1px solid rgba(255,255,255,.13);border-radius:15px;background:rgba(5,28,47,.58)}
.fin-calculator__field label{display:block;margin-bottom:7px;color:rgba(255,255,255,.78);font-size:11px;font-weight:760;line-height:1.3}
.fin-calculator__field input{width:100%;height:46px;padding:0 13px;border:1px solid rgba(57,224,210,.42);border-radius:11px;outline:0;background:#fff;color:#071f35;font-size:clamp(17px,1.85vw,22px);font-weight:850;font-variant-numeric:tabular-nums;transition:border-color .2s ease,box-shadow .2s ease}
.fin-calculator__field input:focus{border-color:#39E0D2;box-shadow:0 0 0 3px rgba(57,224,210,.12)}
.fin-calculator__field small{display:block;margin-top:7px;color:rgba(255,255,255,.45);font-size:10px;line-height:1.35}
.fin-calculator__result{display:flex;min-height:104px;flex-direction:column;justify-content:space-between;background:linear-gradient(145deg,rgba(14,59,91,.96),rgba(7,31,53,.96))}
.fin-calculator__result--accent{border-color:rgba(57,224,210,.48);background:linear-gradient(145deg,rgba(0,196,180,.18),rgba(7,31,53,.98))}
.fin-calculator__result span{color:rgba(255,255,255,.63);font-size:9.5px;font-weight:760;line-height:1.3;text-transform:uppercase;letter-spacing:.035em}
.fin-calculator__result strong{display:block;margin:8px 0 4px;color:#fff;font-size:clamp(18px,2vw,27px);line-height:1;letter-spacing:-.03em;white-space:nowrap;font-variant-numeric:tabular-nums}
.fin-calculator__result--accent strong{color:#39E0D2}
.fin-calculator__result small{color:rgba(255,255,255,.43);font-size:9.5px;line-height:1.3}
.fin-calculator__caption{margin:13px 0 0;color:rgba(255,255,255,.52);font-size:11px;line-height:1.45}
.fin-calculator__caption strong{color:rgba(255,255,255,.78)}
@media(max-width:980px){.fin-calculator__head{grid-template-columns:1fr;align-items:start}.fin-calculator__head p{max-width:none;text-align:left}.fin-calculator__fields{grid-template-columns:repeat(2,minmax(0,1fr))}.fin-calculator__field:last-child{grid-column:1/-1}.fin-calculator__results{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.fin-calculator{padding:16px;border-radius:18px}.fin-calculator__fields,.fin-calculator__results{grid-template-columns:1fr}.fin-calculator__field:last-child{grid-column:auto}.fin-calculator__result{min-height:92px}.fin-calculator__result strong{font-size:22px}}
/* v7 — mobile typography and density audit */
@media(max-width:760px){
.container{width:min(calc(100% - 24px),var(--container))}
.section{padding:clamp(52px,14vw,68px) 0}
.section--compact{--section-y:clamp(48px,12vw,62px)}
h1{font-size:clamp(34px,9.4vw,40px);line-height:1.02;letter-spacing:-.045em;margin-bottom:18px}
h2{font-size:clamp(27px,7.6vw,32px);line-height:1.07;letter-spacing:-.035em;margin-bottom:16px}
h3{font-size:clamp(19px,5.3vw,22px);line-height:1.18}
.lead{font-size:16px;line-height:1.5}
.section-head{margin-bottom:32px}
.eyebrow{margin-bottom:12px;font-size:10.5px;letter-spacing:.12em}
.hero{min-height:auto;padding:112px 0 40px}
.hero__sub{font-size:16px;line-height:1.48;margin-bottom:22px}
.hero__actions{margin-bottom:26px}
.hero-fact{padding:14px 12px}
.hero-fact strong{font-size:21px}
.hero-fact span{font-size:11px;line-height:1.3}
.hero__business{font-size:12.5px;line-height:1.4;align-items:flex-start}
.gap-card h3{font-size:23px;line-height:1.12}
.system-bottom__statement{font-size:20px;line-height:1.22}
.equipment-card h3{font-size:23px}
.program-card h3{font-size:19px}
.timeline-title{font-size:24px;line-height:1.15}
.timeline-step strong{line-height:1.22}
.timeline-step__lead,.timeline-step ul,.timeline-step li strong{font-size:12.5px}
.cta-copy h2{font-size:clamp(28px,7.8vw,32px);line-height:1.08;letter-spacing:-.035em}
.loss-economy__head h2{font-size:clamp(29px,8vw,33px);line-height:1.06;letter-spacing:-.035em}
.loss-economy__panel--loss,.loss-economy__panel--gain{padding:20px 16px}
.loss-total strong{font-size:22px;line-height:1.05}
.loss-breakdown__row{gap:10px}
.loss-breakdown__row span,.loss-breakdown__row strong{font-size:11px;line-height:1.35}
.loss-solution strong{font-size:13px}
.loss-solution span{font-size:10.5px;line-height:1.35}
.loss-formula__cell strong{font-size:21px}
.loss-formula__cell--result strong{font-size:19px}
.loss-metric strong{font-size:13px}
.fin-model{padding:clamp(54px,14vw,70px) 0}
.fin-title{font-size:clamp(28px,7.8vw,32px);line-height:1.07;letter-spacing:-.035em;margin-bottom:10px}
.fin-lead{font-size:15px;line-height:1.5;margin-bottom:24px}
.fin-formula{padding:14px 14px;border-radius:14px;margin-bottom:22px}
.fin-formula--course-note{gap:8px}
.fin-formula__tag{font-size:10px;letter-spacing:.055em}
.fin-formula__copy{font-size:12.2px;line-height:1.43}
.fin-formula__copy p+p{margin-top:5px}
.fin-formula__copy p:first-child{font-size:12.6px;line-height:1.35}
.fin-formula__copy strong{font-weight:780}
.fin-scenarios{gap:12px;margin-bottom:24px}
.fin-scenario{padding:18px 18px;min-height:145px;border-radius:18px}
.fin-scenario strong{font-size:32px}
.fin-scenario b{font-size:23px}
.fin-scenario small{font-size:11px}
.fin-drivers-title{font-size:16px}
.fin-driver{padding:14px 10px;font-size:12px}
.fin-note{margin-top:20px;padding:14px 15px;font-size:11.5px}
.fin-calculator{padding:16px;border-radius:18px}
.fin-calculator__head{gap:8px;margin-bottom:16px}
.fin-calculator__head h3{font-size:19px;line-height:1.15}
.fin-calculator__head p{font-size:11.5px;line-height:1.4}
.fin-calculator__field,.fin-calculator__result{padding:12px}
.fin-calculator__field label{font-size:10.5px}
.fin-calculator__field input{height:44px;font-size:18px}
.fin-calculator__field small{font-size:9.5px;line-height:1.3}
.fin-calculator__result{min-height:84px}
.fin-calculator__result span{font-size:9.5px;line-height:1.25}
.fin-calculator__result strong{font-size:20px}
.fin-calculator__result small{font-size:9px}
.fin-calculator__caption{font-size:10px;line-height:1.4}
}
@media(max-width:430px){
h1{font-size:34px}
h2{font-size:28px}
.hero__sub{font-size:15px}
.fin-formula__copy{font-size:11.7px;line-height:1.4}
.fin-formula__copy p:first-child{font-size:12px}
.fin-formula{padding:13px 12px}
.fin-drivers{gap:8px}
.fin-driver{padding:12px 8px;font-size:11.5px}
.loss-total strong{font-size:21px}
.loss-formula__cell strong{font-size:20px}
.cta-copy h2{font-size:28px}
}
+3661
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "exo-medcenter-landing",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.12"
},
"scripts": {
"dev": "concurrently -n client,server -c cyan,magenta \"vite\" \"tsx watch server/src/index.ts\"",
"dev:client": "vite",
"dev:server": "tsx watch server/src/index.ts",
"build": "tsc -b && vite build",
"preview": "vite preview",
"start": "NODE_ENV=production tsx server/src/index.ts",
"typecheck": "tsc -b --force",
"amo:check": "tsx scripts/amo-check.ts"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tsx": "^4.23.12",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/express": "^5.0.6",
"@types/node": "^26.4.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
"concurrently": "^10.0.5",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="26" fill="#0A2540"/>
<path d="M35 28h33c21 0 36 14 36 35S89 99 68 99H35V28Zm17 17v37h15c11 0 19-8 19-19s-8-18-19-18H52Z" fill="#fff"/>
<circle cx="67" cy="63" r="6" fill="#00C4B4"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+65
View File
@@ -0,0 +1,65 @@
/**
* Verifies the amoCRM connection and prints what the integration will do with
* your account: which pipeline stage leads land in, and which of your custom
* fields the lead form's data was matched to.
*
* npm run amo:check
*/
import { AmoClient } from '../server/src/amocrm.ts'
import { readAmoConfig } from '../server/src/config.ts'
import { resolveLeadFieldMap } from '../server/src/lead-mapper.ts'
const config = readAmoConfig()
if (!config) {
console.error('✗ AMO_SUBDOMAIN and AMO_LONG_LIVED_TOKEN must be set in .env')
process.exit(1)
}
const amo = new AmoClient(config)
console.info(`${config.baseUrl}\n`)
const account = await amo.getAccount()
console.info(`✓ Account: ${account?.name} (id ${account?.id})`)
const pipeline = await amo.getPipeline(config.pipelineId)
if (!pipeline) {
console.error(`✗ Pipeline ${config.pipelineId} not found in this account.`)
process.exit(1)
}
const stages = [...(pipeline._embedded?.statuses ?? [])].sort((a, b) => a.sort - b.sort)
console.info(`✓ Pipeline ${pipeline.id}: «${pipeline.name}»`)
console.info(` Leads will be created in the first stage: «${stages[0]?.name ?? 'unknown'}»`)
console.info(` All stages: ${stages.map((s) => s.name).join(' → ')}\n`)
const leadFields = await amo.getLeadFields()
const map = resolveLeadFieldMap(leadFields)
console.info(`Lead data → amoCRM field mapping (${leadFields.length} custom fields in the account):`)
const keys = [
'company',
'comment',
'page',
'referrer',
'form',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
]
for (const key of keys) {
const field = map.get(key)
console.info(
field
? `${key.padEnd(14)} → «${field.name}» (id ${field.id}, ${field.type})`
: ` · ${key.padEnd(14)} → note on the lead`,
)
}
const contactFields = await amo.getContactFields()
const hasPhone = contactFields.some((f) => f.code === 'PHONE')
const hasEmail = contactFields.some((f) => f.code === 'EMAIL')
console.info(`\nContact fields: PHONE ${hasPhone ? '✓' : '✗ missing'}, EMAIL ${hasEmail ? '✓' : '✗ missing'}`)
console.info('\nAll good — the form is ready to create leads.')
+247
View File
@@ -0,0 +1,247 @@
import type { AmoConfig } from './config.ts'
/* -------------------------------------------------------------------------- */
/* amoCRM REST types (only the parts this integration touches) */
/* -------------------------------------------------------------------------- */
export interface AmoEnum {
id: number
value: string
enum_code?: string | null
}
export interface AmoCustomField {
id: number
name: string
code: string | null
type: string
enums?: AmoEnum[] | null
}
interface AmoFieldValue {
value: string | number | boolean | null
enum_id?: number
enum_code?: string
}
export interface AmoFieldEntry {
field_id?: number
field_code?: string
values: AmoFieldValue[]
}
export interface AmoContact {
id: number
name?: string
custom_fields_values?: AmoFieldEntry[] | null
}
export interface AmoLead {
id: number
}
export class AmoError extends Error {
constructor(
message: string,
readonly status?: number,
readonly detail?: unknown,
) {
super(message)
this.name = 'AmoError'
}
}
/* -------------------------------------------------------------------------- */
/* Client */
/* -------------------------------------------------------------------------- */
const RETRYABLE = new Set([429, 500, 502, 503, 504])
export class AmoClient {
private leadFields?: Promise<AmoCustomField[]>
private contactFields?: Promise<AmoCustomField[]>
constructor(private readonly config: AmoConfig) {}
get baseUrl(): string {
return this.config.baseUrl
}
/**
* A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses
* for "found nothing" on every collection endpoint.
*/
private async request<T>(
path: string,
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
attempt = 1,
): Promise<T | null> {
const url = new URL(path, this.config.baseUrl)
for (const [key, value] of Object.entries(init.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
let response: Response
try {
response = await fetch(url, {
method: init.method ?? 'GET',
headers: {
Authorization: `Bearer ${this.config.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
signal: AbortSignal.timeout(15_000),
})
} catch (cause) {
if (attempt < 3) {
await sleep(attempt * 400)
return this.request<T>(path, init, attempt + 1)
}
throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`)
}
if (response.status === 204) return null
if (!response.ok) {
const detail = await response.text().catch(() => '')
if (RETRYABLE.has(response.status) && attempt < 3) {
await sleep(attempt * 700)
return this.request<T>(path, init, attempt + 1)
}
if (response.status === 401) {
throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail)
}
throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail)
}
return (await response.json()) as T
}
/* ---------------------------------------------------------------------- */
/* Field metadata — fetched once per process, then reused */
/* ---------------------------------------------------------------------- */
private async fetchFields(entity: 'leads' | 'contacts'): Promise<AmoCustomField[]> {
const collected: AmoCustomField[] = []
for (let page = 1; page <= 10; page++) {
const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>(
`/api/v4/${entity}/custom_fields`,
{ query: { page, limit: 250 } },
)
const batch = res?._embedded?.custom_fields ?? []
collected.push(...batch)
if (batch.length < 250) break
}
return collected
}
getLeadFields(): Promise<AmoCustomField[]> {
this.leadFields ??= this.fetchFields('leads')
return this.leadFields
}
getContactFields(): Promise<AmoCustomField[]> {
this.contactFields ??= this.fetchFields('contacts')
return this.contactFields
}
/* ---------------------------------------------------------------------- */
/* Contacts */
/* ---------------------------------------------------------------------- */
/** Full-text search. amoCRM matches phones and emails regardless of format. */
async findContact(queries: string[]): Promise<AmoContact | null> {
for (const query of queries) {
if (!query) continue
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
query: { query, limit: 1, with: 'leads' },
})
const contact = res?._embedded?.contacts?.[0]
if (contact) return contact
}
return null
}
async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise<AmoContact> {
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
method: 'POST',
body: [
{
name: input.name,
responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined,
},
],
})
const contact = res?._embedded?.contacts?.[0]
if (!contact) throw new AmoError('amoCRM did not return the created contact')
return contact
}
/** Adds phone/email to an existing contact without dropping what is there. */
async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise<void> {
if (!fields.length) return
await this.request(`/api/v4/contacts/${contactId}`, {
method: 'PATCH',
body: { custom_fields_values: fields },
})
}
/* ---------------------------------------------------------------------- */
/* Leads */
/* ---------------------------------------------------------------------- */
/**
* Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want.
*/
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] }
const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name }))
const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', {
method: 'POST',
body: [
{
name: input.name,
pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded,
},
],
})
const lead = res?._embedded?.leads?.[0]
if (!lead) throw new AmoError('amoCRM did not return the created lead')
return lead
}
async addLeadNote(leadId: number, text: string): Promise<void> {
await this.request(`/api/v4/leads/${leadId}/notes`, {
method: 'POST',
body: [{ note_type: 'common', params: { text } }],
})
}
/* ---------------------------------------------------------------------- */
/* Diagnostics */
/* ---------------------------------------------------------------------- */
async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> {
return this.request('/api/v4/account')
}
async getPipeline(id: number): Promise<{
id: number
name: string
_embedded?: { statuses?: { id: number; name: string; sort: number }[] }
} | null> {
return this.request(`/api/v4/leads/pipelines/${id}`)
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+61
View File
@@ -0,0 +1,61 @@
import { config as loadEnv } from 'dotenv'
loadEnv()
function optional(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function optionalNumber(name: string): number | undefined {
const raw = optional(name)
if (raw === undefined) return undefined
const value = Number(raw)
if (!Number.isFinite(value)) throw new Error(`${name} must be a number, got "${raw}"`)
return value
}
/**
* amoCRM settings. Deliberately not throwing when they are missing: the site
* must still boot and serve pages without a CRM connection — only the lead
* endpoint degrades, and it says so explicitly.
*/
export interface AmoConfig {
baseUrl: string
token: string
pipelineId: number
responsibleUserId?: number
tags: string[]
}
export function readAmoConfig(): AmoConfig | null {
const subdomain = optional('AMO_SUBDOMAIN')
const token = optional('AMO_LONG_LIVED_TOKEN')
if (!subdomain || !token) return null
// Accepts "exotherapy", "exotherapy.amocrm.ru", or a full URL (an explicit
// scheme is kept as-is, which is what local mocks and self-hosted setups need).
const host = subdomain.replace(/\/+$/, '')
const baseUrl = /^https?:\/\//.test(host)
? host
: `https://${host.includes('.') ? host : `${host}.${optional('AMO_DOMAIN') ?? 'amocrm.ru'}`}`
return {
baseUrl,
token,
pipelineId: optionalNumber('AMO_PIPELINE_ID') ?? 10980758,
responsibleUserId: optionalNumber('AMO_RESPONSIBLE_USER_ID'),
tags: (optional('AMO_LEAD_TAGS') ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}
}
export const serverConfig = {
port: optionalNumber('PORT') ?? 3002,
// Loopback by default: in production nginx is the only thing that should
// reach this process, so the port is never exposed to the network.
host: optional('HOST') ?? '127.0.0.1',
isProduction: process.env.NODE_ENV === 'production',
}
+115
View File
@@ -0,0 +1,115 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { leadSchema, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts'
import { createRateLimiter } from './rate-limit.ts'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const clientDir = path.join(rootDir, 'dist/client')
const amoConfig = readAmoConfig()
const leadService = amoConfig ? new LeadService(new AmoClient(amoConfig)) : null
if (!leadService) {
console.warn(
'[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN are not set — the site runs, but /api/leads/* will return 503.',
)
}
const app = express()
// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the
// address nginx actually observed and the rate limiter below cannot be
// side-stepped with a forged X-Forwarded-For header.
app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
})
app.post('/api/leads/medical-centers-existing-physio', async (req, res) => {
const send = (status: number, body: LeadResponse) => res.status(status).json(body)
const parsed = leadSchema.safeParse(req.body)
if (!parsed.success) {
const fields: Record<string, string> = {}
for (const issue of parsed.error.issues) {
const key = issue.path.join('.')
if (key && !fields[key]) fields[key] = issue.message
}
return send(400, { ok: false, error: 'Проверьте заполненные поля.', fields })
}
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[lead] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0, contactId: 0 })
}
const { allowed, retryAfterSeconds } = limiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много заявок с этого адреса. Попробуйте позже.' })
}
if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' })
}
try {
const result = await leadService.submit(payload)
console.info(
'[lead] amoCRM lead %d (contact %d, %s)',
result.leadId,
result.contactId,
result.contactCreated ? 'new contact' : 'existing contact',
)
return send(200, { ok: true, leadId: result.leadId, contactId: result.contactId })
} catch (error) {
console.error('[lead] amoCRM submission failed:', error)
console.error('[lead] payload was:', JSON.stringify(payload))
const message =
error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.'
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.'
return send(502, { ok: false, error: message })
}
})
if (serverConfig.isProduction) {
app.use(
express.static(clientDir, {
index: false,
setHeaders(res, filePath) {
// Vite fingerprints everything under /assets, so it can be cached hard.
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
},
}),
)
// SPA fallback. Written as middleware because Express 5 no longer accepts a
// bare '*' route pattern.
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
res.sendFile(path.join(clientDir, 'index.html'))
})
}
app.listen(serverConfig.port, serverConfig.host, () => {
console.info(
'[server] listening on http://localhost:%d%s',
serverConfig.port,
serverConfig.isProduction ? ` (serving ${path.relative(rootDir, clientDir)})` : ' (API only — run vite for the UI)',
)
})
+143
View File
@@ -0,0 +1,143 @@
import type { LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** Human labels used in the fallback note. */
const LABELS: Record<string, string> = {
company: 'Медицинский центр',
email: 'Email',
phone: 'Телефон',
comment: 'Комментарий',
page: 'Страница',
referrer: 'Источник перехода',
form: 'Форма',
utm_source: 'utm_source',
utm_medium: 'utm_medium',
utm_campaign: 'utm_campaign',
utm_content: 'utm_content',
utm_term: 'utm_term',
}
/**
* Candidate amoCRM field codes / name fragments for each piece of data we
* collect. The first field in the account that matches wins; anything without
* a match falls through to the lead note instead.
*/
const CANDIDATES: Record<string, { codes: string[]; names: string[] }> = {
company: {
codes: ['COMPANY', 'COMPANY_NAME'],
names: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
},
comment: { codes: ['COMMENT', 'MESSAGE', 'DESCRIPTION'], names: ['комментарий', 'сообщение', 'описание заявки'] },
page: { codes: ['REFERRER', 'PAGE'], names: ['страница', 'посадочная страница', 'url страницы'] },
referrer: { codes: ['REFERER_URL'], names: ['источник перехода', 'referrer', 'реферер'] },
form: { codes: ['FORMNAME', 'FORM_NAME'], names: ['название формы', 'форма'] },
utm_source: { codes: ['UTM_SOURCE'], names: ['utm_source'] },
utm_medium: { codes: ['UTM_MEDIUM'], names: ['utm_medium'] },
utm_campaign: { codes: ['UTM_CAMPAIGN'], names: ['utm_campaign'] },
utm_content: { codes: ['UTM_CONTENT'], names: ['utm_content'] },
utm_term: { codes: ['UTM_TERM'], names: ['utm_term'] },
}
/** Field types we know how to write a plain string into. */
const TEXTUAL = new Set(['text', 'textarea', 'url', 'tracking_data', 'numeric', 'price', 'monetary'])
const ENUMERATED = new Set(['select', 'radiobutton', 'multiselect'])
const normalise = (value: string) =>
value
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[^a-zа-я0-9]+/gi, ' ')
.trim()
export type LeadFieldMap = Map<string, AmoCustomField>
/** Picks one account field per payload key. A field is never used twice. */
export function resolveLeadFieldMap(fields: AmoCustomField[]): LeadFieldMap {
const map: LeadFieldMap = new Map()
const taken = new Set<number>()
for (const [key, candidate] of Object.entries(CANDIDATES)) {
const byCode = fields.find(
(f) => f.code && candidate.codes.includes(f.code.toUpperCase()) && !taken.has(f.id),
)
const match =
byCode ??
fields.find((f) => {
if (taken.has(f.id)) return false
const name = normalise(f.name)
return candidate.names.some((n) => name === normalise(n))
}) ??
fields.find((f) => {
if (taken.has(f.id)) return false
const name = normalise(f.name)
return candidate.names.some((n) => name.includes(normalise(n)))
})
if (!match) continue
if (!TEXTUAL.has(match.type) && !ENUMERATED.has(match.type)) continue
map.set(key, match)
taken.add(match.id)
}
return map
}
/** Builds one amoCRM field entry, or null when the value cannot be represented. */
function toFieldEntry(field: AmoCustomField, value: string): AmoFieldEntry | null {
if (ENUMERATED.has(field.type)) {
const target = normalise(value)
const option = field.enums?.find((e) => normalise(e.value) === target)
// An unmatched option would silently create garbage, so let it hit the note.
return option ? { field_id: field.id, values: [{ value: option.value, enum_id: option.id }] } : null
}
if (field.type === 'numeric' || field.type === 'price' || field.type === 'monetary') {
const numeric = Number(value.replace(/[^\d.,-]/g, '').replace(',', '.'))
return Number.isFinite(numeric) ? { field_id: field.id, values: [{ value: numeric }] } : null
}
return { field_id: field.id, values: [{ value }] }
}
export interface MappedLead {
fields: AmoFieldEntry[]
/** Everything that had no matching field, ready to be written as a note. */
note: string
}
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
const data: Record<string, string | undefined> = {
company: payload.company,
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на усиление кабинета физиотерапии',
...payload.utm,
}
const fields: AmoFieldEntry[] = []
const leftovers: string[] = []
for (const [key, value] of Object.entries(data)) {
if (!value) continue
const field = fieldMap.get(key)
const entry = field ? toFieldEntry(field, value) : null
if (entry) fields.push(entry)
else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
}
// Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card.
const header = [
'Заявка с лендинга «Усиление действующего кабинета физиотерапии»',
`${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title shown in the pipeline. */
export function buildLeadName(payload: LeadPayload): string {
return `Физиотерапия — ${payload.company ?? payload.name}`
}
+124
View File
@@ -0,0 +1,124 @@
import type { LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '')
/**
* Last ten digits — the part that is stable across "+7 (999) 123-45-67",
* "8 999 1234567" and "9991234567". Used to decide whether amoCRM already
* knows a phone number.
*/
const phoneKey = (value: string) => digitsOnly(value).slice(-10)
/**
* amoCRM's full-text search matches digit substrings, but a contact stored as
* "+7 999…" is not found by a query starting with "8 999…". Try every spelling
* a Russian visitor might type.
*/
function phoneQueries(phone: string): string[] {
const digits = digitsOnly(phone)
const queries = new Set<string>([phone.trim(), digits])
if (digits.length >= 10) {
const national = digits.slice(-10)
queries.add(national)
queries.add(`7${national}`)
queries.add(`8${national}`)
}
return [...queries].filter(Boolean)
}
/** Reads the values already stored in a contact's standard multitext field. */
function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] {
const entry = contact.custom_fields_values?.find((f) => f.field_code === code)
return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean)
}
function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] {
const entries: AmoFieldEntry[] = [
{ field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] },
]
if (payload.email) {
entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] })
}
return entries
}
export interface CreatedLead {
leadId: number
contactId: number
contactCreated: boolean
}
export class LeadService {
private fieldMap?: Promise<LeadFieldMap>
constructor(private readonly amo: AmoClient) {}
private getFieldMap(): Promise<LeadFieldMap> {
this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
return this.fieldMap
}
async submit(payload: LeadPayload): Promise<CreatedLead> {
const fieldMap = await this.getFieldMap()
// 1. Reuse the existing contact when the phone or email is already known.
const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? ''])
let contactId: number
let contactCreated = false
if (existing) {
contactId = existing.id
// Add whichever channel amoCRM does not have on file yet.
const missing: AmoFieldEntry[] = []
const knownPhones = existingValues(existing, 'PHONE').map(phoneKey)
if (!knownPhones.includes(phoneKey(payload.phone))) {
missing.push({
field_code: 'PHONE',
values: [
...existingValues(existing, 'PHONE').map((value) => ({ value })),
{ value: payload.phone, enum_code: 'WORK' },
],
})
}
if (payload.email) {
const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase())
if (!knownEmails.includes(payload.email.toLowerCase())) {
missing.push({
field_code: 'EMAIL',
values: [
...existingValues(existing, 'EMAIL').map((value) => ({ value })),
{ value: payload.email, enum_code: 'WORK' },
],
})
}
}
await this.amo.appendContactFields(contactId, missing)
} else {
const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) })
contactId = created.id
contactCreated = true
}
// 2. Lead in the configured pipeline, first stage.
const { fields, note } = mapLead(payload, fieldMap)
const lead = await this.amo.createLead({
name: buildLeadName(payload),
contactId,
fields,
tags: ['заявка с сайта'],
})
// 3. Everything the account has no field for lands in a readable note.
// A failed note must not fail the lead — the lead is the valuable part.
if (note) {
await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => {
console.warn('[amo] lead %d created but the note failed:', lead.id, error)
})
}
return { leadId: lead.id, contactId, contactCreated }
}
}
+33
View File
@@ -0,0 +1,33 @@
interface Bucket {
count: number
resetAt: number
}
/**
* Minimal fixed-window limiter for the public lead endpoint. In-memory on
* purpose: a single landing page behind one Node process does not warrant a
* shared store, and losing the counters on restart is harmless.
*/
export function createRateLimiter(options: { limit: number; windowMs: number }) {
const buckets = new Map<string, Bucket>()
return function check(key: string): { allowed: boolean; retryAfterSeconds: number } {
const now = Date.now()
if (buckets.size > 5000) {
for (const [k, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(k)
}
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + options.windowMs })
return { allowed: true, retryAfterSeconds: 0 }
}
bucket.count += 1
return {
allowed: bucket.count <= options.limit,
retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000),
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod'
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
export const leadFormIds = ['request'] as const
export type LeadFormId = (typeof leadFormIds)[number]
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.optional()
.transform((v) => (v ? v : undefined))
export const leadSchema = z.object({
form: z.enum(leadFormIds),
name: z.string().trim().min(1, 'Укажите имя').max(200),
phone: z.string().trim().min(5, 'Укажите телефон').max(50),
email: z
.union([z.email().max(200), z.literal('')])
.optional()
.transform((v) => (v ? v : undefined)),
company: optionalText(200),
comment: optionalText(4000),
page: optionalText(300),
referrer: optionalText(500),
// partialRecord, not record: z.record over an enum requires every key.
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
/** Honeypot. Bots fill it in; humans never see it. */
website: z.string().max(200).optional(),
})
export type LeadPayload = z.infer<typeof leadSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
export type LeadInput = z.input<typeof leadSchema>
export type LeadResponse =
| { ok: true; leadId: number; contactId: number }
| { ok: false; error: string; fields?: Record<string, string> }
+40
View File
@@ -0,0 +1,40 @@
import { EquipmentSection } from './components/EquipmentSection'
import { Hero } from './components/Hero'
import { LaunchSection } from './components/LaunchSection'
import { LossEconomySection } from './components/LossEconomySection'
import { MobileActionBar } from './components/MobileActionBar'
import { MarketSection } from './components/MarketSection'
import { ProgramsSection } from './components/ProgramsSection'
import { ProjectSection } from './components/ProjectSection'
import { RequestSection } from './components/RequestSection'
import { ResidencySection } from './components/ResidencySection'
import { ScenariosSection } from './components/ScenariosSection'
import { SiteFooter } from './components/SiteFooter'
import { SiteHeader } from './components/SiteHeader'
import { WhyExoSection } from './components/WhyExoSection'
export default function App() {
return (
<>
<SiteHeader />
<main>
<Hero />
<MarketSection />
<LossEconomySection />
<ScenariosSection />
<ProjectSection />
<EquipmentSection />
<ProgramsSection />
<LaunchSection />
<WhyExoSection />
<RequestSection />
</main>
<ResidencySection />
<SiteFooter />
<MobileActionBar />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="330" height="88" viewBox="0 0 330 88" role="img" aria-label="Участник проекта Сколково">
<rect width="330" height="88" rx="16" fill="#ffffff"/>
<g transform="translate(16 19) scale(1.02)">
<path fill="#b1ec52" d="M12.31 0C5.51 0 0 5.51 0 12.31v24.62c0 6.8 5.51 12.31 12.31 12.31h24.62c6.8 0 12.31-5.51 12.31-12.31V0H12.31Z"/>
<path fill="#4d5759" d="M40.69 18.46h-5.91l-5.62 6.16V12.31H24.6v2.98c-.15-.19-.31-.38-.48-.57-1.66-1.76-4.05-2.65-7.11-2.65-3.53 0-5.54 1.58-6.61 2.91-1.32 1.65-1.91 3.9-1.5 5.73 1.06 4.74 5.46 5.5 8.69 5.92 2.55.33 4.99.8 4.93 3.07-.07 2.36-2.73 2.94-4.48 2.94-4.55 0-4.7-3.36-4.7-3.36H8.45c.07 1.26.45 3.29 2.01 5.03 1.7 1.91 4.26 2.87 7.59 2.87 2.56 0 4.89-.83 6.55-2.31v2.03h4.56v-5.81l1.61-1.77 4.61 7.58h5.31l-6.71-11.09 6.71-7.35ZM13.46 19.66c-.4-1.76 1.35-3.32 3.63-3.32 2.05 0 4.1.46 4.88 3.31h2.62v4.36c-3.27-2.69-10.37-1-11.13-4.35Z"/>
</g>
<text x="82" y="37" font-family="Arial, Helvetica, sans-serif" font-size="22" font-weight="700" letter-spacing="0.6" fill="#4d5759">УЧАСТНИК</text>
<text x="82" y="59" font-family="Arial, Helvetica, sans-serif" font-size="13" font-weight="600" letter-spacing="0.7" fill="#4d5759">ПРОЕКТА «СКОЛКОВО»</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

+69
View File
@@ -0,0 +1,69 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
import { cx } from '../lib/cx'
export type ButtonVariant = 'primary' | 'secondary' | 'call'
const base =
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none whitespace-nowrap ' +
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
'[&>svg]:size-[18px] [&>svg]:shrink-0'
/**
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
* conflicting utilities by their order in the stylesheet, not by the order they
* appear in `className`, so a `min-h-[44px]` passed next to the default
* `min-h-[52px]` would silently lose. Replacing the slot means the losing class
* is never emitted in the first place.
*/
const defaultSize = 'min-h-[52px] px-[22px]'
/** `.btn--header` — smaller, and tighter still on the narrowest phones. */
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] phone:px-[14px] phone:text-[12px]'
const variants: Record<ButtonVariant, string> = {
primary:
'bg-teal text-navy shadow-[0_16px_45px_rgb(0_196_180/0.26)] hover:bg-teal-bright hover:shadow-[0_20px_55px_rgb(0_196_180/0.34)]',
secondary:
'border-white/[0.28] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.44] hover:bg-white/[0.14]',
call: 'border-white/[0.22] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.38] hover:bg-white/[0.14] [&>svg]:size-[16px]',
}
export function buttonClass(variant: ButtonVariant, className?: string, size: string = defaultSize) {
return cx(base, size, variants[variant], className)
}
interface CommonProps {
variant?: ButtonVariant
/** Replaces the default min-height/padding pair. See `defaultSize` above. */
size?: string
className?: string
children: ReactNode
}
export function Button({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size'>) {
return (
<button className={buttonClass(variant, className, size)} {...rest}>
{children}
</button>
)
}
export function ButtonLink({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a className={buttonClass(variant, className, size)} {...rest}>
{children}
</a>
)
}
@@ -0,0 +1,91 @@
import { devices } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, ScrollHint, Section, SectionHead } from './Layout'
/**
* Product shots differ in framing, so the last stylesheet pass in the original
* sized three of them separately. Keyed by the device title.
*/
const mediaFit: Record<string, string> = {
ЭкзоИмпульс: 'max-w-[88%] max-h-[88%] translate-y-[10px]',
'ЭкзоЛазер В': 'max-w-[82%] max-h-[82%] p-[2px] narrow:max-w-[86%] narrow:max-h-[86%]',
}
const defaultFit = 'max-w-[86%] max-h-[86%] translate-y-[8px]'
export function EquipmentSection() {
const track = useReveal<HTMLDivElement>()
const note = useReveal<HTMLDivElement>()
return (
<Section id="technology" tone="dark" className="overflow-hidden">
<Container>
<SectionHead
split
tone="dark"
eyebrow="Технологический конструктор"
title="Существующее оборудование + недостающие EXO-модули"
lead="Для действующего кабинета не нужно покупать всё сразу. По итогам аудита выбираются платформы, которые закрывают конкретные клинические и операционные пробелы — без повторения уже имеющихся факторов."
/>
<div
ref={track.ref}
aria-label="Оборудование ЭКЗО Групп"
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)}
>
{devices.map((device) => (
<article
key={device.title}
className="equipment-card-surface flex min-h-[488px] flex-col overflow-hidden rounded-[26px] border border-white/[0.13]"
>
<div className="equipment-media-surface flex h-[300px] items-center justify-center overflow-hidden px-[18px] pt-[18px] pb-[14px]">
<img
alt={device.alt}
className={cx(
'block h-auto w-auto object-contain object-center drop-shadow-[0_18px_24px_rgb(0_0_0/0.28)]',
mediaFit[device.title] ?? defaultFit,
)}
loading="lazy"
src={device.image}
/>
</div>
<div className="flex flex-1 flex-col p-[22px]">
<span className="mb-[8px] text-[11px] font-[850] tracking-[0.12em] text-teal-bright uppercase">
{device.kicker}
</span>
<h3 className="text-[27px] text-white">{device.title}</h3>
<p className="mb-[16px] text-[13px] leading-[1.55] text-white/[0.72]">{device.indications}</p>
<div className="mt-auto border-t border-white/[0.12] pt-[15px] text-[13px] leading-[1.55] font-[720] text-[#C7FFF9]">
{device.role}
</div>
</div>
</article>
))}
</div>
<ScrollHint tone="dark" className="md:hidden">
На мобильном листайте карточки в сторону
</ScrollHint>
<div
ref={note.ref}
className={cx(
'mt-[22px] flex items-start gap-[12px] rounded-md border border-teal/[0.26] bg-teal/[0.09] px-[20px] py-[18px] text-[13px] text-white/[0.76]',
'[&>svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright',
note.revealClass,
)}
>
<CheckIcon />
<span>
Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая
технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и
формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.
</span>
</div>
</Container>
</Section>
)
}
@@ -0,0 +1,63 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import { cx } from '../lib/cx'
/** Glass controls on the navy CTA panel. */
const controlClass =
'w-full rounded-[14px] border border-white/[0.18] bg-white/[0.08] text-white outline-none ' +
'transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-white/[0.42] ' +
'focus:border-teal focus:bg-white/[0.12] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.12)]'
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
return (
<div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}>
<label htmlFor={id} className="text-[12px] font-[750] text-white/[0.7]">
{label}
</label>
{children}
</div>
)
}
export function TextField({
id,
label,
full,
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
return (
<Field id={id} label={label} full={full}>
<input id={id} className={cx(controlClass, 'min-h-[52px] px-[15px]')} {...rest} />
</Field>
)
}
export function TextareaField({
id,
label,
full,
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
return (
<Field id={id} label={label} full={full}>
<textarea id={id} className={cx(controlClass, 'min-h-[104px] resize-y px-[15px] pt-[14px]')} {...rest} />
</Field>
)
}
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<div aria-hidden="true" className="honeypot">
<label htmlFor="website">Не заполнять</label>
<input
autoComplete="off"
id="website"
name="website"
tabIndex={-1}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import heroBg from '../assets/images/hero-bg.webp'
import { heroFacts, heroFactsNote } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { Container, Eyebrow } from './Layout'
export function Hero() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
return (
<section
id="top"
aria-labelledby="hero-title"
className="relative isolate flex min-h-[780px] items-end overflow-hidden bg-navy-deep pt-[126px] pb-[48px] text-white lg:min-h-[810px]"
>
<div
aria-hidden="true"
className="hero-photo absolute inset-0 -z-3"
style={{ backgroundImage: `url(${heroBg})` }}
/>
<div aria-hidden="true" className="hero-scrim absolute inset-0 -z-2" />
<div
aria-hidden="true"
className="teal-bloom pointer-events-none absolute -bottom-[240px] -left-[150px] -z-1 size-[650px] rounded-full blur-[8px]"
/>
<Container className="relative z-2">
<div ref={ref} className={cx('max-w-[860px]', revealClass)}>
<Eyebrow tone="dark">Решение для медицинского центра с действующей физиотерапией</Eyebrow>
<h1 id="hero-title">Усилим кабинет физиотерапии без замены действующего оборудования</h1>
<p className="mb-[28px] max-w-[760px] text-[clamp(19px,2vw,24px)] leading-[1.52] text-white/[0.8]">
Проводим аудит оборудования, документы и загрузку, сохраняем полезные активы, убираем дублирование,
добавляем только недостающие технологии и объединяем старое и новое в единые клинические маршруты.
</p>
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] sm:flex-row sm:items-center">
<ButtonLink href="#proposal">
Получить предложение
<ArrowRightIcon />
</ButtonLink>
<ButtonLink href="#launch" variant="secondary">
Посмотреть модели усиления
</ButtonLink>
</div>
<div
aria-label="Ключевые экономические показатели"
className="grid max-w-[780px] grid-cols-1 gap-[10px] sm:grid-cols-3"
>
{heroFacts.map((fact) => (
<div
key={fact.value}
className="min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]"
>
<strong className="mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]">
{fact.value}
</strong>
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
</div>
))}
</div>
<p className="mt-[10px] max-w-[760px] text-[11px] leading-[1.45] text-white/[0.52]">{heroFactsNote}</p>
<div className="mt-[18px] inline-flex items-center gap-[9px] text-[14px] font-[720] text-[#C8FFF9]">
<i aria-hidden="true" className="size-[8px] rounded-full bg-teal shadow-[0_0_0_7px_rgb(0_196_180/0.14)]" />
Рост загрузки уже сделанных инвестиций вместо тотальной замены оборудования
</div>
</div>
</Container>
</section>
)
}
+180
View File
@@ -0,0 +1,180 @@
import type { ReactNode } from 'react'
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
function Icon({ children }: { children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
{children}
</svg>
)
}
export function ArrowRightIcon() {
return (
<Icon>
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function CheckIcon() {
return (
<Icon>
<path d="m5 12 4 4L19 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.2" />
</Icon>
)
}
export function PhoneIcon() {
return (
<Icon>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.35 1.78.68 2.62a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.46-1.25a2 2 0 0 1 2.11-.45c.84.33 1.72.56 2.62.68A2 2 0 0 1 22 16.92Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</Icon>
)
}
/* -------------------------------------------------------------------------- */
/* Экономика действующего кабинета */
/* -------------------------------------------------------------------------- */
/** Panel title: «Где вы теряете деньги уже сегодня». */
export function WarningIcon() {
return (
<Icon>
<path d="M12 3 2.8 19a1.4 1.4 0 0 0 1.2 2h16a1.4 1.4 0 0 0 1.2-2L12 3Z" stroke="currentColor" strokeWidth="1.8" />
<path d="M12 8v5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
<circle cx="12" cy="17" r="1" fill="currentColor" />
</Icon>
)
}
/** Panel title: «Как мы превращаем потери в дополнительную выручку». */
export function TrendUpIcon() {
return (
<Icon>
<path d="M4 18 9 13l4 4 7-9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
<path d="M15 8h5v5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
)
}
/** Left anchor of the leak diagram: the patient base. */
export function PatientsIcon() {
return (
<Icon>
<path
d="M16 20v-1.5A4.5 4.5 0 0 0 11.5 14h-5A4.5 4.5 0 0 0 2 18.5V20"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
<circle cx="9" cy="7" r="4" stroke="currentColor" strokeWidth="1.7" />
<path d="M17 10a3 3 0 1 0 0-6M22 20v-1.5a4.5 4.5 0 0 0-3.2-4.3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
</Icon>
)
}
/** Right anchor of the leak diagram: what is left of the revenue. */
export function RevenueBarsIcon() {
return (
<Icon>
<path d="M4 19V9m6 10V5m6 14v-7m4 7V3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</Icon>
)
}
/** Closing statement: turn the base into steady growth. */
export function GrowthBarsIcon() {
return (
<Icon>
<path d="M5 19V9m5 10v-6m5 6V5m4 14V3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</Icon>
)
}
/** Icons of the four «как превращаем потери» tiles, keyed by `leakSolutions[].icon`. */
export const solutionIcons = {
gear: (
<Icon>
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" strokeWidth="1.7" />
<path
d="m19.4 15 .6 1.1-2 3.4-1.3-.1a8.4 8.4 0 0 1-1.5.9l-.6 1.2h-4l-.6-1.2a8.4 8.4 0 0 1-1.5-.9l-1.3.1-2-3.4.6-1.1a8 8 0 0 1 0-2l-.6-1.1 2-3.4 1.3.1a8.4 8.4 0 0 1 1.5-.9l.6-1.2h4l.6 1.2a8.4 8.4 0 0 1 1.5.9l1.3-.1 2 3.4-.6 1.1a8 8 0 0 1 0 2Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</Icon>
),
clipboard: (
<Icon>
<path d="M8 4h8m-7-2h6a1 1 0 0 1 1 1v3H8V3a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.7" />
<rect x="5" y="5" width="14" height="17" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M9 11h6M9 15h6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
</Icon>
),
repeat: (
<Icon>
<path d="M20 12a8 8 0 0 1-14.7 4.4M4 12A8 8 0 0 1 18.7 7.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
<path d="M5 18v-4h4M19 6v4h-4" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
</Icon>
),
layers: (
<Icon>
<path d="m12 3 8 4-8 4-8-4 8-4Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
<path d="m4 12 8 4 8-4M4 17l8 4 8-4" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
</Icon>
),
}
/* -------------------------------------------------------------------------- */
/* Модернизация и «почему Экзо» */
/* -------------------------------------------------------------------------- */
/** Icons of the four promise cards, keyed by `promises[].icon`. */
export const promiseIcons = {
load: (
<Icon>
<path d="M4 19V7l8-4 8 4v12H4Z M8 19v-6h8v6" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
gap: (
<Icon>
<path d="M4 17 9 12l4 4 7-9 M20 7v5h-5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
throughput: (
<Icon>
<path d="M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
</Icon>
),
result: (
<Icon>
<path d="M5 20V10M12 20V4M19 20v-7" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
),
}
/** Icons of the «почему Экзо Групп» list, keyed by `whyItems[].icon`. */
export const whyIcons = {
audit: (
<Icon>
<path d="M4 17V7l8-4 8 4v10l-8 4-8-4Z M9 9h6v6H9z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
clinic: (
<Icon>
<path d="M5 20v-8M12 20V4M19 20v-5M3 20h18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
),
roles: (
<Icon>
<path d="M4 5h16v11H4zM8 20h8M12 16v4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</Icon>
),
}
@@ -0,0 +1,76 @@
import { formats, timeline } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Section, SectionHead, SmallNote } from './Layout'
export function LaunchSection() {
const grid = useReveal<HTMLDivElement>()
const steps = useReveal<HTMLDivElement>()
return (
<Section id="launch" tone="white">
<Container>
<SectionHead
split
eyebrow="Три модели усиления"
title="Можно начать точечно — без длительного закрытия кабинета"
lead="Формат определяется после аудита оборудования, расписания и клинических пробелов. Расширение происходит только после того, как пилот подтверждает назначения, загрузку, результат и экономику."
/>
<div
ref={grid.ref}
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)}
>
{formats.map((format) => (
<article
key={format.area}
className="relative overflow-hidden rounded-[22px] border border-navy/[0.12] bg-white p-[24px]"
>
<span
aria-hidden="true"
className="absolute -top-[30px] -right-[30px] size-[110px] rounded-full bg-teal/[0.08]"
/>
<span className="mb-[9px] block text-[clamp(36px,5vw,52px)] leading-none font-[850] tracking-[-0.055em] text-navy">
{format.area}
</span>
<h3 className="text-[20px]">{format.title}</h3>
<p className="text-[14px] text-muted">{format.text}</p>
</article>
))}
</div>
<SmallNote className="mt-[16px]">
Финальная конфигурация определяется по техническому состоянию аппаратов, документации, площади, лицензии,
штату, потоку пациентов и текущей загрузке.
</SmallNote>
<h3 className="mt-[30px] mb-[38px] text-[clamp(24px,3vw,34px)] font-[850] tracking-[-0.03em] text-navy">
Пошаговый план модернизации кабинета за 8 недель
</h3>
<div
ref={steps.ref}
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[14px] md:grid-still md:grid-cols-2', steps.revealClass)}
>
{timeline.map((step) => (
<article key={step.week} className="rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[22px]">
<span className="mb-[8px] block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">
{step.week}
</span>
<strong className="mb-[10px] block text-[18px] leading-[1.2] text-navy">{step.title}</strong>
<span className="mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span>
<ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted">
{step.items.map((item) => (
<li key={item.title}>
<strong className="inline text-[13px] text-navy">{item.title}</strong>
{'text' in item && item.text ? `: ${item.text}` : null}
</li>
))}
</ul>
</article>
))}
</div>
</Container>
</Section>
)
}
+169
View File
@@ -0,0 +1,169 @@
import type { ReactNode } from 'react'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
type Tone = 'light' | 'dark'
/** `.container` — 1200px max, 16px gutters that grow to 24px from 760px up. */
export function Container({ className, children }: { className?: string; children: ReactNode }) {
return (
<div
className={cx(
'mx-auto w-[min(calc(100%_-_32px),var(--container-page))] md:w-[min(calc(100%_-_48px),var(--container-page))]',
className,
)}
>
{children}
</div>
)
}
/** `.eyebrow` — small uppercase kicker with the leading teal rule. */
export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<p
className={cx(
'mb-[16px] inline-flex items-center gap-[10px] text-[12px] font-[850] tracking-[0.14em] uppercase',
"before:h-[2px] before:w-[27px] before:rounded-[2px] before:bg-teal before:content-['']",
tone === 'dark' ? 'text-teal-bright' : 'text-teal-ink',
className,
)}
>
{children}
</p>
)
}
/** `.lead` — the paragraph under a section heading. */
export function SectionLead({
tone = 'light',
className,
children,
}: {
tone?: Tone
className?: string
children: ReactNode
}) {
return (
<p
className={cx(
'mb-0 max-w-[780px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
className,
)}
>
{children}
</p>
)
}
/** `.small-note` — the fine print under a section. */
export function SmallNote({
tone = 'light',
className,
children,
}: {
tone?: Tone
className?: string
children: ReactNode
}) {
return (
<p className={cx('text-[13px] leading-[1.55]', tone === 'dark' ? 'text-white/[0.56]' : 'text-muted', className)}>
{children}
</p>
)
}
/**
* `.section-head` — stacked by default; `split` moves the lead into a second
* column from 760px up, bottom-aligned against the heading.
*/
export function SectionHead({
eyebrow,
title,
lead,
tone = 'light',
split = false,
}: {
eyebrow: string
title: ReactNode
lead?: ReactNode
tone?: Tone
split?: boolean
}) {
const { ref, revealClass } = useReveal<HTMLDivElement>()
if (!split) {
return (
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2>{title}</h2>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
return (
<div
ref={ref}
className={cx(
'mb-[clamp(34px,5vw,54px)] grid gap-[20px] md:grid-cols-[minmax(0,1.15fr)_minmax(300px,0.85fr)] md:items-end',
revealClass,
)}
>
<div>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2 className="mb-0">{title}</h2>
</div>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
const sectionTones = {
light: 'bg-paper',
white: 'bg-white',
dark: 'bg-navy text-white',
/** For sections that paint their own multi-layer background in index.css. */
none: '',
}
/** `.section` — the shared vertical rhythm and background variants. */
export function Section({
id,
tone = 'light',
compact = false,
className,
children,
}: {
id?: string
tone?: keyof typeof sectionTones
compact?: boolean
className?: string
children: ReactNode
}) {
return (
<section
id={id}
className={cx('relative', compact ? 'py-section-compact' : 'py-section', sectionTones[tone], className)}
>
{children}
</section>
)
}
/** `.scroll-hint` — only meaningful while the card row still scrolls sideways. */
export function ScrollHint({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<div
aria-hidden="true"
className={cx(
"mt-[12px] flex items-center gap-[8px] text-[11px] before:h-px before:w-[24px] before:bg-current before:content-['']",
tone === 'dark' ? 'text-white/[0.48]' : 'text-muted',
className,
)}
>
{children}
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { useState, type FormEvent } from 'react'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { submitLead } from '../lib/lead'
import { formatRuPhone, isCompleteRuPhone } from '../lib/phone'
import { Button } from './Button'
import { Honeypot, TextField, TextareaField } from './FormField'
import { ArrowRightIcon } from './Icons'
const EMPTY = {
name: '',
company: '',
phone: '',
email: '',
comment: '',
}
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
export function LeadForm() {
const { ref: formRef, revealClass } = useReveal<HTMLFormElement>()
const [values, setValues] = useState(EMPTY)
const [honeypot, setHoneypot] = useState('')
const [pending, setPending] = useState(false)
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
const set = (key: keyof typeof EMPTY) => (event: { target: { value: string } }) =>
setValues((current) => ({ ...current, [key]: event.target.value }))
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = formRef.current
if (!form) return
if (honeypot) return
if (!form.checkValidity()) {
form.reportValidity()
setStatus({ text: 'Проверьте обязательные поля формы.', tone: 'error' })
return
}
if (!isCompleteRuPhone(values.phone)) {
setStatus({ text: 'Укажите полный номер телефона.', tone: 'error' })
form.phone.focus()
return
}
setPending(true)
setStatus({ text: '', tone: 'idle' })
const result = await submitLead('request', { ...values, website: honeypot })
if (result.ok) {
setValues(EMPTY)
setStatus({
text: 'Спасибо. Заявка отправлена — специалист свяжется с вами для аудита действующего кабинета.',
tone: 'success',
})
} else {
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
}
setPending(false)
}
return (
<form
ref={formRef}
id="leadForm"
noValidate
onSubmit={onSubmit}
className={cx(
'rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]',
revealClass,
)}
>
<div className="grid gap-[12px] sm:grid-cols-2">
<TextField
id="name"
label="Имя *"
name="name"
autoComplete="name"
placeholder="Как к вам обращаться"
required
value={values.name}
onChange={set('name')}
/>
<TextField
id="company"
label="Компания / медицинский центр"
name="company"
autoComplete="organization"
placeholder="Название центра"
value={values.company}
onChange={set('company')}
/>
<TextField
id="phone"
label="Телефон *"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
placeholder="+7 900 000-00-00"
required
value={values.phone}
onChange={(event) => setValues((current) => ({ ...current, phone: formatRuPhone(event.target.value) }))}
/>
<TextField
id="email"
label="Email"
name="email"
type="email"
autoComplete="email"
placeholder="name@clinic.ru"
value={values.email}
onChange={set('email')}
/>
<TextareaField
id="comment"
label="Комментарий"
name="comment"
full
placeholder="Какие аппараты уже есть, сколько процедур проводится, площадь, город и главная задача модернизации"
value={values.comment}
onChange={set('comment')}
/>
<Honeypot value={honeypot} onChange={setHoneypot} />
<div className="sm:col-span-full">
<Button type="submit" className="mt-[4px] w-full" disabled={pending} aria-busy={pending}>
<span>{pending ? 'Отправляем…' : 'Получить предложение'}</span>
{pending ? null : <ArrowRightIcon />}
</Button>
</div>
</div>
<p className="mt-[10px] text-[11px] text-white/[0.45]">
Нажимая кнопку, вы подтверждаете согласие на обработку данных для подготовки предложения.
</p>
{/* Kept mounted so screen readers announce the result in place. */}
<div
role="status"
aria-live="polite"
className={cx(
status.text && 'mt-[12px] rounded-[13px] border px-[15px] py-[13px] text-[13px]',
status.tone === 'success' && 'border-teal/[0.3] bg-teal/[0.15] text-[#C8FFF9]',
status.tone === 'error' && 'border-[#FF6060]/[0.28] bg-[#FF6060]/[0.12] text-[#FFD6D6]',
)}
>
{status.text}
</div>
</form>
)
}
@@ -0,0 +1,324 @@
import type { ReactNode } from 'react'
import {
leakBreakdown,
leakSolutions,
leakStages,
revenueFormula,
revenueMetrics,
revenueResult,
} from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import {
CheckIcon,
GrowthBarsIcon,
PatientsIcon,
RevenueBarsIcon,
TrendUpIcon,
WarningIcon,
solutionIcons,
} from './Icons'
import { Container, Eyebrow, Section, SectionLead } from './Layout'
export function LossEconomySection() {
const head = useReveal<HTMLDivElement>()
const loss = useReveal<HTMLElement>()
const gain = useReveal<HTMLElement>()
const closing = useReveal<HTMLDivElement>()
return (
<Section id="money-leak" tone="none" className="loss-section-surface overflow-hidden">
<span
aria-hidden="true"
className="pointer-events-none absolute -top-[220px] left-[52%] size-[560px] rounded-full bg-[radial-gradient(circle,rgb(0_196_180/0.13),transparent_68%)]"
/>
<Container className="relative z-1">
<div ref={head.ref} className={cx('mb-[clamp(32px,4.5vw,54px)] max-w-[960px]', head.revealClass)}>
<Eyebrow>Экономика действующего кабинета</Eyebrow>
<h2 className="mb-[14px] text-[clamp(40px,5.7vw,68px)]">Деньги уходят мимо Вас</h2>
<SectionLead className="max-w-[880px]">
Вы уже вложились в кабинет и оборудование, но выручка не растёт. Проблема не в трафике проблема в том, как
используется текущая база.
</SectionLead>
</div>
<div className="grid items-stretch gap-[24px] stack:grid-cols-1 grid-cols-[minmax(0,1.03fr)_minmax(0,1fr)]">
{/* ------------------------------------------------------------- */}
{/* Где вы теряете деньги */}
{/* ------------------------------------------------------------- */}
<article
ref={loss.ref}
className={cx(
'loss-panel-surface overflow-hidden rounded-[30px] border border-teal-bright/[0.18] p-[30px] text-white shadow-[0_28px_80px_rgb(4_30_48/0.12)]',
'narrow:rounded-[24px] narrow:px-[18px] narrow:py-[22px]',
loss.revealClass,
)}
>
<PanelTitle tone="dark">
<WarningIcon />
Где вы теряете деньги уже сегодня
</PanelTitle>
<div className="grid items-end gap-[14px] narrow:grid-cols-1 narrow:items-stretch grid-cols-[102px_minmax(0,1fr)_102px]">
<FlowAnchor value="1 000" label="активных пациентов в базе за год">
<PatientsIcon />
</FlowAnchor>
<div
aria-label="Этапы потери выручки"
className="relative grid min-w-0 grid-cols-4 gap-[6px] narrow:grid-cols-2 tiny:grid-cols-1"
>
{/* The pipe the leaks drip out of. Hidden once the stages wrap. */}
<span
aria-hidden="true"
className="loss-pipe absolute top-[99px] right-[5%] left-[5%] z-0 h-[13px] rounded-full stack:top-[93px] narrow:hidden"
/>
{leakStages.map((stage) => (
<div
key={stage.percent}
className="relative z-1 min-w-0 text-center narrow:rounded-[15px] narrow:border narrow:border-white/10 narrow:bg-white/[0.045] narrow:px-[10px] narrow:py-[13px]"
>
<div className="flex min-h-[42px] items-end justify-center text-[10px] leading-[1.2] text-white/[0.88] narrow:min-h-0 narrow:items-start narrow:text-[11px]">
<span>
{stage.label[0]}
<br className="narrow:hidden" />{' '}
{stage.label[1]}
</span>
</div>
<strong className="my-[5px] mb-[7px] block text-[20px] leading-none font-[850] text-teal-bright narrow:mt-[8px] narrow:mb-0">
{stage.percent}
</strong>
<div aria-hidden="true" className="loss-hole relative mx-auto mb-[30px] size-[31px] rounded-full narrow:hidden" />
</div>
))}
</div>
<FlowAnchor value="≈200" label="пациентов доходят до выручки за год">
<RevenueBarsIcon />
</FlowAnchor>
</div>
<div className="mt-[24px] grid gap-[14px] border-t border-white/[0.12] pt-[20px] narrow:grid-cols-1 grid-cols-[minmax(180px,0.8fr)_minmax(0,1.2fr)]">
<div className="rounded-md border border-white/10 bg-white/[0.045] p-[18px]">
<span className="block text-[10px] leading-[1.3] tracking-[0.04em] text-white/[0.55] uppercase">
Сколько денег уходит мимо вас каждый год
</span>
<strong className="mt-[7px] mb-[5px] block text-[clamp(18px,1.95vw,26px)] leading-[1.02] tracking-[-0.04em] whitespace-nowrap tabular-nums text-white">
20 000 000
</strong>
<small className="block text-[11px] leading-[1.4] text-white/[0.62]">
потенциальной выручки теряется из-за неиспользованного потенциала текущей базы
</small>
</div>
<div className="grid content-center gap-[8px]">
{leakBreakdown.map((row) => (
<div
key={row.label}
className="grid grid-cols-[1fr_auto] items-center gap-[12px] border-b border-white/[0.09] pb-[7px] text-[10px] leading-[1.25] last:border-b-0 last:pb-0"
>
<span className="text-white/[0.68]">{row.label}</span>
<strong className="text-[12px] whitespace-nowrap text-white">{row.value}</strong>
</div>
))}
</div>
</div>
</article>
{/* ------------------------------------------------------------- */}
{/* Как превращаем потери в выручку */}
{/* ------------------------------------------------------------- */}
<article
ref={gain.ref}
className={cx(
'overflow-hidden rounded-[30px] border border-teal/[0.28] bg-white/[0.88] p-[30px] shadow-[0_28px_80px_rgb(4_30_48/0.12)] backdrop-blur-[12px]',
'narrow:rounded-[24px] narrow:px-[18px] narrow:py-[22px]',
gain.revealClass,
)}
>
<PanelTitle tone="light">
<TrendUpIcon />
Как мы превращаем потери в дополнительную выручку
</PanelTitle>
<div className="grid grid-cols-2 gap-[11px] narrow:grid-cols-1">
{leakSolutions.map((solution) => (
<div
key={solution.title}
className="loss-solution-surface grid min-h-[112px] grid-cols-[38px_1fr] items-start gap-[11px] rounded-[17px] border border-navy/[0.11] p-[16px]"
>
<span className="grid size-[38px] place-items-center rounded-[13px] bg-ice text-teal-ink [&>svg]:size-[21px]">
{solutionIcons[solution.icon]}
</span>
<div>
<strong className="mb-[4px] block text-[14px] leading-[1.2] text-navy">{solution.title}</strong>
<span className="block text-[11px] leading-[1.38] text-muted">{solution.text}</span>
</div>
</div>
))}
</div>
<p className="mt-[22px] mb-[12px] text-[11px] font-[850] tracking-[0.06em] text-teal-ink uppercase">
Результат: выручка растёт за счёт текущей базы
</p>
<div
aria-label="Сценарный расчёт годовой выручки"
className="grid items-stretch gap-[7px] narrow:grid-cols-1 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto_minmax(0,0.8fr)_auto_minmax(0,1.35fr)]"
>
{revenueFormula.map((cell, index) => (
<FormulaFragment key={cell.label} operator={index === 0 ? undefined : '×'} {...cell} />
))}
<FormulaFragment operator="=" result {...revenueResult} />
</div>
<div className="mt-[13px] grid grid-cols-4 gap-[8px] rounded-[17px] border border-teal/[0.22] bg-[#EFFAF9] p-[14px] narrow:grid-cols-2 narrow:gap-[12px]">
{revenueMetrics.map((metric) => (
<div
key={metric.label}
className="min-w-0 border-r border-navy/[0.1] pr-[8px] last:border-r-0 last:pr-0
narrow:border-r-0 narrow:border-b narrow:pr-0 narrow:pb-[10px] narrow:[&:nth-last-child(-n+2)]:border-b-0 narrow:[&:nth-last-child(-n+2)]:pb-0"
>
<strong className="block text-[14px] leading-[1.15] whitespace-nowrap tabular-nums text-teal-deep">
{metric.value}
</strong>
<span className="mt-[4px] block text-[8.5px] leading-[1.3] text-muted">{metric.label}</span>
</div>
))}
</div>
</article>
</div>
<div
ref={closing.ref}
className={cx(
'mt-[22px] grid items-center gap-[24px] rounded-[22px] border border-teal/[0.22] bg-white/90 px-[24px] py-[20px] shadow-soft',
'narrow:grid-cols-1 narrow:p-[18px] tiny:rounded-md grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]',
closing.revealClass,
)}
>
<ClosingItem divider icon={<CheckIcon />}>
<p className="text-[14px] leading-[1.45] text-navy">Мы не приводим новых пациентов.</p>
<p className="mt-[3px] text-[14px] leading-[1.45] text-navy">
Мы увеличиваем выручку с тех, кто уже у вас есть.
</p>
</ClosingItem>
<ClosingItem icon={<GrowthBarsIcon />}>
<p className="text-[14px] leading-[1.45] text-navy">
<strong className="font-[850]">Не теряйте деньги.</strong>
<br />
Превратите свою базу в источник стабильного роста.
</p>
</ClosingItem>
</div>
<p className="mt-[14px] text-[11px] leading-[1.5] text-muted">
Сценарная модель. Фактический потенциал рассчитывается после аудита базы, расписания, действующего
оборудования, тарифов и структуры курсов.
</p>
</Container>
</Section>
)
}
function PanelTitle({ tone, children }: { tone: 'light' | 'dark'; children: ReactNode }) {
return (
<h3
className={cx(
'mb-[22px] flex items-center gap-[10px] text-[13px] leading-[1.25] font-[850] tracking-[0.09em] uppercase',
'[&>svg]:size-[22px] [&>svg]:shrink-0 [&>svg]:text-teal tiny:text-[11px]',
tone === 'dark' ? 'text-white' : 'text-navy',
)}
>
{children}
</h3>
)
}
/** The patient-base / surviving-revenue blocks on either side of the leak pipe. */
function FlowAnchor({ value, label, children }: { value: string; label: string; children: ReactNode }) {
return (
<div
className="loss-anchor-surface flex min-h-[118px] flex-col items-center justify-center rounded-[17px] border border-teal-bright/[0.48] px-[10px] py-[15px] text-center
narrow:grid narrow:min-h-0 narrow:grid-cols-[34px_auto_1fr] narrow:items-center narrow:justify-start narrow:gap-[10px] narrow:text-left
[&>svg]:mb-[5px] [&>svg]:size-[28px] [&>svg]:text-teal-bright narrow:[&>svg]:m-0"
>
{children}
<strong className="block text-[25px] leading-none font-[850] text-teal-bright narrow:text-[22px]">{value}</strong>
<span className="mt-[7px] block text-[10px] leading-[1.3] text-white/[0.68] narrow:m-0">{label}</span>
</div>
)
}
/** One `× value` pair of the revenue formula — operator first, then the cell. */
function FormulaFragment({
operator,
value,
label,
result = false,
}: {
operator?: string
value: string
label: string
result?: boolean
}) {
return (
<>
{operator ? (
<div
aria-hidden="true"
className="self-center text-[21px] font-extrabold text-teal-ink narrow:justify-self-center narrow:rotate-90 narrow:leading-none"
>
{operator}
</div>
) : null}
<div
className={cx(
'flex min-w-0 flex-col justify-center rounded-[15px] border px-[8px] py-[13px] text-center narrow:px-[12px] narrow:py-[15px]',
result
? 'loss-result-surface border-transparent shadow-[0_13px_30px_rgb(0_196_180/0.22)]'
: 'border-teal/[0.28] bg-white',
)}
>
<strong
className={cx(
'block leading-[1.05] tracking-[-0.03em] whitespace-nowrap tabular-nums',
result ? 'text-[clamp(16px,1.35vw,20px)] text-[#062B3D]' : 'text-[clamp(16px,1.9vw,22px)] stack:text-[24px] text-navy',
)}
>
{value}
</strong>
<span className={cx('mt-[5px] block text-[9px] leading-[1.25]', result ? 'text-[#062B3D]' : 'text-muted')}>
{label}
</span>
</div>
</>
)
}
function ClosingItem({
icon,
divider = false,
children,
}: {
icon: ReactNode
divider?: boolean
children: ReactNode
}) {
return (
<div
className={cx(
'grid min-w-0 grid-cols-[44px_1fr] items-center gap-[14px]',
divider && 'border-r border-navy/[0.12] pr-[24px] narrow:border-r-0 narrow:border-b narrow:pr-0 narrow:pb-[18px]',
)}
>
<span className="grid size-[44px] place-items-center rounded-full border border-teal bg-ice text-teal-ink [&>svg]:size-[23px]">
{icon}
</span>
<div>{children}</div>
</div>
)
}
@@ -0,0 +1,171 @@
import {
cabinetAsProcedures,
cabinetAsSystem,
marketSources,
marketStats,
trendBars,
trendChartLabel,
} from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
export function MarketSection() {
const stats = useReveal<HTMLDivElement>()
const gap = useReveal<HTMLElement>()
return (
<Section id="market">
<Container>
<SectionHead
split
eyebrow="Рыночный контекст"
title="Спрос на реабилитацию растёт. Кабинет должен расти вместе с ним"
lead="Физиотерапия уже есть — точка роста не в новом запуске, а в максимальном использовании уже имеющейся площади, оборудования, команды и потока пациентов"
/>
<div className="grid gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
<div
ref={stats.ref}
className={cx(
'scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:grid-cols-2',
stats.revealClass,
)}
>
{marketStats.map((stat) => (
<article
key={stat.label}
className="relative flex min-h-[185px] flex-col overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
phone:min-h-[172px] phone:p-[20px] sm:min-h-[205px]"
>
<span
aria-hidden="true"
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full"
/>
<strong className="relative z-1 mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
{stat.value}
</strong>
<p className="relative z-1 max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
{/* In flow under `mt-auto`, not pinned to the card's bottom: the
longest label wraps to four lines at phone card width and ran
straight through an absolutely placed source line. */}
<span className="relative z-1 mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
{stat.source}
</span>
</article>
))}
</div>
<article
ref={gap.ref}
className={cx(
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep',
gap.revealClass,
)}
>
<span
aria-hidden="true"
className="teal-bloom absolute -right-[160px] -bottom-[200px] size-[480px] rounded-full"
/>
<div className="relative z-1">
<Eyebrow tone="dark">Динамика в России</Eyebrow>
<h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)]">
Количество пациентов растёт. Конкурирует уже не наличие кабинета, а качество маршрута
</h3>
</div>
<div className="relative z-1 mt-[4px] border-t border-white/[0.13] pt-[20px]">
<div className="mb-[18px] flex items-end justify-between gap-[18px] phone:flex-col phone:items-start phone:gap-[4px]">
<strong className="text-[18px] leading-[1.2]">Пациенты медицинской реабилитации по ОМС</strong>
<span className="text-right text-[12px] text-white/[0.58] phone:text-left">млн человек · 20222025</span>
</div>
<div
aria-label={trendChartLabel}
className="flex h-[196px] items-end gap-[10px] border-b border-l border-white/[0.15] px-[10px] pt-[14px] phone:h-[174px] phone:gap-[6px] phone:px-[6px]"
>
{trendBars.map((bar) => (
<div key={bar.year} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-[8px]">
<span className="text-[12px] font-extrabold whitespace-nowrap text-white phone:text-[11px]">
{bar.value}
</span>
<div
className="trend-bar min-h-[28px] w-[min(62px,72%)] rounded-t-[14px] rounded-b-[3px]"
style={{ height: bar.height }}
/>
<span className="text-[12px] font-[760] text-white/[0.64]">{bar.year}</span>
</div>
))}
</div>
</div>
<div className="relative z-1 grid gap-[12px] md:grid-cols-2">
<CabinetColumn sign="—" title="Кабинет как набор процедур" items={cabinetAsProcedures} />
<CabinetColumn sign="+" title="Кабинет как система" items={cabinetAsSystem} accent />
</div>
</article>
</div>
<SmallNote className="mt-[16px]">
Источники рыночных показателей:{' '}
{marketSources.map((source, index) => (
<span key={source.href}>
<a
className="underline decoration-navy/[0.25] underline-offset-[3px]"
href={source.href}
rel="noopener"
target="_blank"
>
{source.label}
</a>
{index === marketSources.length - 1 ? '. ' : index === marketSources.length - 2 ? ' и ' : ', '}
</span>
))}
Показатели описывают рынок и не прогнозируют загрузку конкретного центра.
</SmallNote>
</Container>
</Section>
)
}
function CabinetColumn({
sign,
title,
items,
accent = false,
}: {
sign: string
title: string
items: string[]
accent?: boolean
}) {
return (
<div
className={cx(
'rounded-[20px] border p-[20px]',
accent ? 'border-teal/[0.34] bg-teal/[0.13]' : 'border-white/[0.13] bg-white/[0.07]',
)}
>
<div className="mb-[12px] flex items-center gap-[10px] font-[820]">
<span
aria-hidden="true"
className="grid size-[28px] place-items-center rounded-full bg-white/[0.12] text-[13px] text-teal-bright"
>
{sign}
</span>
{title}
</div>
<ul className="grid gap-[9px]">
{items.map((item) => (
<li
key={item}
className="relative pl-[19px] text-[14px] text-white/[0.72] before:absolute before:top-[0.65em] before:left-0 before:size-[7px] before:rounded-full before:bg-teal before:content-['']"
>
{item}
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { contacts } from '../data/content'
import { cx } from '../lib/cx'
import { ButtonLink, buttonClass } from './Button'
import { PhoneIcon } from './Icons'
/**
* The primary CTA's home below 760px, where the header cannot hold it without
* crushing the logo. See `SiteHeader`.
*
* It waits for the hero — whose own two buttons say the same thing — to scroll
* away, and steps back down while the form is on screen, so it never sits on
* top of the fields it points at. That also keeps it clear of the submit
* button, which is the one control it could plausibly be mistaken for.
*/
export function MobileActionBar() {
const [shown, setShown] = useState(false)
useEffect(() => {
const hero = document.getElementById('top')
const form = document.getElementById('proposal')
if (!hero || !form) return
let heroOnScreen = true
let formOnScreen = false
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.target === hero) heroOnScreen = entry.isIntersecting
if (entry.target === form) formOnScreen = entry.isIntersecting
}
setShown(!heroOnScreen && !formOnScreen)
})
observer.observe(hero)
observer.observe(form)
return () => observer.disconnect()
}, [])
return (
<div
/* Not `hidden` when down: it is off-screen, and `inert` already takes it
out of the tab order, so the slide stays animatable both ways. */
inert={!shown || undefined}
className={cx(
'fixed inset-x-0 bottom-0 z-[55] border-t border-white/[0.1] bg-navy-deep/[0.94] backdrop-blur-[18px] md:hidden',
'px-[16px] pt-[12px] pb-[max(12px,env(safe-area-inset-bottom))]',
'transition-transform duration-300 ease-[cubic-bezier(0.2,0.65,0.3,1)]',
shown ? 'translate-y-0' : 'translate-y-full',
)}
>
<div className="flex items-center gap-[10px]">
<a
href={contacts.phoneHref}
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
className={buttonClass('call', 'shrink-0', 'min-h-[52px] w-[52px] px-0')}
>
<PhoneIcon />
</a>
<ButtonLink href="#proposal" className="flex-1">
Получить предложение
</ButtonLink>
</div>
</div>
)
}
@@ -0,0 +1,76 @@
import { programs, routeSteps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, ScrollHint, Section, SectionHead } from './Layout'
export function ProgramsSection() {
const route = useReveal<HTMLElement>()
return (
<Section id="programs" compact>
<Container>
<SectionHead
eyebrow="Комбинированные маршруты"
title="Старые и новые аппараты перестают быть отдельными услугами"
lead="Действующие процедуры, ЛФК, массаж и EXO-технологии объединяются в курс под состояние пациента, этап реабилитации и утверждённую клиническую модель."
/>
<div className="scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5">
{programs.map((program) => (
<ProgramCard key={program.num} {...program} />
))}
</div>
<ScrollHint className="md:hidden">На мобильном листайте программы</ScrollHint>
<aside
ref={route.ref}
className={cx('mt-[24px] rounded-xl bg-navy p-[clamp(24px,4vw,36px)] text-white shadow-deep md:mt-[18px]', route.revealClass)}
>
<Eyebrow tone="dark">Операционная модель</Eyebrow>
<h3>Текущий поток превращается в курсовой продукт без остановки работы кабинета</h3>
<div className="mt-[22px] grid gap-[9px] md:grid-cols-5 md:gap-[8px]">
{routeSteps.map((step, index) => (
<div
key={step.title}
className="grid grid-cols-[42px_1fr] items-center gap-[13px] rounded-[17px] border border-white/[0.11] bg-white/[0.06] p-[15px]
md:block md:min-h-[170px] md:p-[14px]"
>
<span className="flex size-[42px] min-w-[42px] items-center justify-center rounded-[13px] bg-teal text-center leading-none font-black tabular-nums text-navy md:mb-[12px]">
{index + 1}
</span>
<div>
<strong className="mb-[2px] block text-[16px] leading-[1.18] [overflow-wrap:anywhere] [hyphens:auto]">
{step.title}
</strong>
<span className="block text-[13px] leading-[1.42] text-white/[0.63] [overflow-wrap:anywhere] [hyphens:auto]">
{step.text}
</span>
</div>
</div>
))}
</div>
</aside>
</Container>
</Section>
)
}
function ProgramCard({ num, title, text }: (typeof programs)[number]) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
className={cx(
'min-h-[230px] rounded-[22px] border border-navy/[0.12] bg-white p-[23px] shadow-[0_15px_45px_rgb(4_30_48/0.055)] md:min-h-[255px]',
revealClass,
)}
>
<span className="mb-[22px] block text-[13px] font-black tracking-[0.12em] text-[#00A99C]">{num}</span>
<h3 className="text-[clamp(19px,1.75vw,23px)] leading-[1.12] [overflow-wrap:anywhere] [hyphens:auto]">{title}</h3>
<p className="text-[14px] leading-[1.48] text-muted [overflow-wrap:anywhere] [hyphens:auto]">{text}</p>
</article>
)
}
@@ -0,0 +1,68 @@
import { profileChips, promises } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { promiseIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
export function ProjectSection() {
const strip = useReveal<HTMLDivElement>()
const panel = useReveal<HTMLDivElement>()
return (
<Section id="project" tone="white">
<Container>
<SectionHead
eyebrow="Модернизация без лишней закупки"
title="Сохраняем полезное. Добавляем недостающее"
lead="Мы начинаем не с аппаратов ЭКЗО, а с аудита уже сделанных инвестиций клиники: технических возможностей, документов, клинической роли, назначений, загрузки и экономики каждого аппарата."
/>
<div
ref={strip.ref}
className={cx(
'scroll-cards mb-[24px] auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:mb-[28px] sm:grid-cols-2 md:grid-cols-4',
strip.revealClass,
)}
>
{promises.map((promise) => (
<article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]">
<div className="mb-[14px] grid size-[42px] place-items-center rounded-[13px] bg-ice text-navy [&>svg]:size-[22px]">
{promiseIcons[promise.icon]}
</div>
<h3 className="text-[20px]">{promise.title}</h3>
<p className="text-[14px] text-muted">{promise.text}</p>
</article>
))}
</div>
<div
ref={panel.ref}
className={cx(
'system-panel-surface rounded-xl border border-navy/[0.12] p-[clamp(24px,4vw,40px)] shadow-[0_22px_70px_rgb(4_30_48/0.08)]',
panel.revealClass,
)}
>
<div className="grid items-center gap-[18px] md:grid-cols-[minmax(0,1fr)_minmax(340px,0.85fr)]">
<div className="text-[clamp(22px,3.3vw,34px)] leading-[1.16] font-extrabold tracking-[-0.035em]">
Инвестиция направляется не на замену всего текущего оборудования, а на закрытие конкретных клинических и
операционных разрывов.
</div>
<div>
<SmallNote className="mb-[10px]">Профили, для которых особенно актуальна модернизация:</SmallNote>
<div className="flex flex-wrap gap-[8px]">
{profileChips.map((chip) => (
<span
key={chip}
className="rounded-full bg-ice px-[12px] py-[8px] text-[12px] font-[720] text-[#176A67]"
>
{chip}
</span>
))}
</div>
</div>
</div>
</div>
</Container>
</Section>
)
}
@@ -0,0 +1,39 @@
import { ctaChecks } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, Eyebrow, SectionLead } from './Layout'
import { LeadForm } from './LeadForm'
export function RequestSection() {
const copy = useReveal<HTMLDivElement>()
return (
<section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white">
<Container className="relative grid items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
<div ref={copy.ref} className={cx(copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="text-[clamp(39px,6vw,68px)]">Усилим действующий кабинет без лишнего дублирования</h2>
<SectionLead tone="dark">
Оставьте контакты специалист уточнит состав оборудования, услуги, загрузку, врачебные направления и задачи
центра, после чего подготовит предварительную дорожную карту модернизации.
</SectionLead>
<ul className="mt-[25px] grid gap-[10px]">
{ctaChecks.map((check) => (
<li
key={check}
className="flex gap-[10px] text-[14px] text-white/[0.75] [&>svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright"
>
<CheckIcon />
<span>{check}</span>
</li>
))}
</ul>
</div>
<LeadForm />
</Container>
</section>
)
}
@@ -0,0 +1,53 @@
import skolkovo from '../assets/images/resident-skolkovo.svg'
import zhiguli from '../assets/images/resident-zhiguli.svg'
import { residencies } from '../data/content'
const logos = [zhiguli, skolkovo]
export function ResidencySection() {
return (
<section
aria-label="Резидентские статусы ЭКЗО Групп"
className="residency-surface relative isolate overflow-hidden border-t border-white/[0.08] py-[34px] pb-[16px] text-white md:pt-[42px] md:pb-[20px]"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-[200px] -right-[120px] -z-1 size-[360px] rounded-full bg-teal/[0.16] blur-[80px]"
/>
<div className="mx-auto flex w-[min(calc(100%_-_32px),var(--container-page))] flex-col gap-[20px] md:w-[min(calc(100%_-_48px),var(--container-page))] md:flex-row md:items-center md:justify-between md:gap-[34px]">
<div className="grid max-w-[580px] gap-[6px]">
<span className="text-[10px] leading-[1.2] font-[850] tracking-[0.16em] text-teal">
ИННОВАЦИОННАЯ ЭКОСИСТЕМА
</span>
<strong className="text-[clamp(18px,2.6vw,27px)] leading-[1.15] tracking-[-0.02em] text-white">
Экзо Групп резидент ведущих технологических площадок
</strong>
</div>
<div className="grid w-[min(100%,540px)] grid-cols-2 gap-[11px] compact:grid-cols-1 md:flex-[0_0_min(50%,540px)]">
{residencies.map((residency, index) => (
<a
key={residency.href}
aria-label={residency.label}
className="flex min-h-[82px] min-w-0 items-center justify-center rounded-md border border-white/[0.75] bg-white/[0.97] px-[16px] py-[12px]
shadow-[0_16px_40px_rgb(0_0_0/0.13)] transition-[transform,box-shadow,border-color] duration-200
hover:-translate-y-[3px] hover:border-teal/[0.72] hover:shadow-[0_20px_46px_rgb(0_0_0/0.2)] compact:min-h-[78px]"
href={residency.href}
rel="noopener noreferrer"
target="_blank"
>
<img
alt={residency.label}
className="block h-auto max-h-[58px] w-auto max-w-full object-contain compact:max-h-[54px]"
src={logos[index]}
width={residency.width}
height={residency.height}
/>
</a>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,137 @@
import { useState } from 'react'
import { calculatorCopy, calculatorFields, calculatorResults } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
const decimal = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
type FieldKey = (typeof calculatorFields)[number]['key']
/**
* Values are held as strings, not numbers: clearing a field has to leave it
* empty while the visitor retypes, and coercing on every keystroke would snap
* it back to 0. Anything that is not a positive finite number reads as 0 and
* blanks the dependent results.
*/
const initialValues = Object.fromEntries(
calculatorFields.map((field) => [field.key, String(field.initial)]),
) as Record<FieldKey, string>
function toNumber(raw: string): number {
const value = Number(raw.replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(value) && value > 0 ? value : 0
}
const rubles = (value: number) => (value ? `${money.format(value)}` : '—')
export function RevenueCalculator() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
const [values, setValues] = useState(initialValues)
const courses = toNumber(values.courses)
const averageCheck = toNumber(values.averageCheck)
const investment = toNumber(values.investment)
const month = courses && averageCheck ? Math.round(courses * averageCheck) : 0
const year = month * 12
const payback = month && investment ? investment / month : null
const roi = month && investment ? ((year - investment) / investment) * 100 : null
const results: Record<(typeof calculatorResults)[number]['key'], string> = {
month: rubles(month),
year: rubles(year),
payback: payback === null ? '—' : `${decimal.format(payback)} мес.`,
roi: roi === null || !Number.isFinite(roi) ? '—' : `${money.format(Math.round(roi))}%`,
}
return (
<div
ref={ref}
className={cx(
'mb-[30px] rounded-[22px] border border-teal-bright/[0.34] p-[clamp(18px,2.2vw,26px)]',
'bg-[linear-gradient(135deg,rgb(0_196_180/0.08),rgb(255_255_255/0.03))] shadow-[0_18px_46px_rgb(0_0_0/0.14)]',
'narrow:rounded-[18px] narrow:p-[16px]',
revealClass,
)}
>
<div className="mb-[17px] grid items-start gap-[18px] narrow:mb-[16px] narrow:gap-[8px] lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)] lg:items-end">
<div>
<span className="mb-[6px] block text-[10px] font-[850] tracking-[0.12em] text-teal-bright uppercase">
{calculatorCopy.eyebrow}
</span>
<h3 className="text-[clamp(20px,2.15vw,28px)] leading-[1.1] tracking-[-0.025em] text-white narrow:text-[19px] narrow:leading-[1.15]">
{calculatorCopy.title}
</h3>
</div>
<p className="text-[13px] leading-[1.45] text-white/[0.61] narrow:text-[11.5px] narrow:leading-[1.4] lg:max-w-[430px] lg:text-right">
{calculatorCopy.lead}
</p>
</div>
<div className="mb-[12px] grid gap-[12px] sm:grid-cols-2 lg:grid-cols-3">
{calculatorFields.map((field) => (
<div
key={field.key}
className="min-w-0 rounded-[15px] border border-white/[0.13] bg-[rgb(5_28_47/0.58)] p-[14px] narrow:p-[12px] sm:last:col-span-2 lg:last:col-span-1"
>
<label
htmlFor={`calc-${field.key}`}
className="mb-[7px] block text-[11px] leading-[1.3] font-[760] text-white/[0.78] narrow:text-[10.5px]"
>
{field.label}
</label>
<input
id={`calc-${field.key}`}
type="number"
inputMode="numeric"
min={0}
step={field.step}
aria-label={field.ariaLabel}
value={values[field.key]}
onChange={(event) =>
setValues((current) => ({ ...current, [field.key]: event.target.value }))
}
className="h-[46px] w-full rounded-[11px] border border-teal-bright/[0.42] bg-white px-[13px] text-[clamp(17px,1.85vw,22px)] font-[850] tabular-nums text-[#071f35] outline-none
transition-[border-color,box-shadow] duration-200 focus:border-teal-bright focus:shadow-[0_0_0_3px_rgb(69_228_215/0.12)] narrow:h-[44px] narrow:text-[18px]"
/>
<small className="mt-[7px] block text-[10px] leading-[1.35] text-white/[0.45] narrow:text-[9.5px] narrow:leading-[1.3]">
{field.hint}
</small>
</div>
))}
</div>
<div aria-live="polite" className="grid gap-[12px] sm:grid-cols-2 lg:grid-cols-4">
{calculatorResults.map((result) => (
<div
key={result.key}
className={cx(
'flex min-h-[104px] min-w-0 flex-col justify-between rounded-[15px] border p-[14px] narrow:min-h-[84px] narrow:p-[12px]',
result.accent
? 'border-teal-bright/[0.48] bg-[linear-gradient(145deg,rgb(0_196_180/0.18),rgb(7_31_53/0.98))]'
: 'border-white/[0.13] bg-[linear-gradient(145deg,rgb(14_59_91/0.96),rgb(7_31_53/0.96))]',
)}
>
<span className="text-[9.5px] leading-[1.3] font-[760] tracking-[0.035em] text-white/[0.63] uppercase narrow:leading-[1.25]">
{result.label}
</span>
<strong
className={cx(
'my-[8px] mb-[4px] block text-[clamp(18px,2vw,27px)] leading-none tracking-[-0.03em] whitespace-nowrap tabular-nums narrow:text-[20px]',
result.accent ? 'text-teal-bright' : 'text-white',
)}
>
{results[result.key]}
</strong>
<small className="text-[9.5px] leading-[1.3] text-white/[0.43] narrow:text-[9px]">{result.hint}</small>
</div>
))}
</div>
<p className="mt-[13px] text-[11px] leading-[1.45] text-white/[0.52] narrow:text-[10px] narrow:leading-[1.4]">
<strong className="font-[850] text-white/[0.78]">Как читать расчёт:</strong> {calculatorCopy.caption}
</p>
</div>
)
}
@@ -0,0 +1,104 @@
import { revenueDrivers, revenueFormulaCopy, revenueScenarios } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { RevenueCalculator } from './RevenueCalculator'
import { Container, Eyebrow, Section } from './Layout'
export function ScenariosSection() {
const cta = useReveal<HTMLDivElement>()
return (
<Section id="financial-scenarios" tone="none" className="bg-scenario text-white">
<Container>
<Eyebrow tone="dark" className="mb-[14px]">
Сценарии выручки
</Eyebrow>
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em]">
Даже умеренная загрузка кабинета даёт понятную выручку
</h2>
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72]">
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
срок окупаемости.
</p>
{/* The tag column is fixed at 170px so the copy beside it keeps one
measure across the three paragraphs; below 760px it stacks. */}
<div className="mb-[28px] grid items-start gap-[22px] rounded-md border border-teal/[0.28] bg-white/[0.035] px-[24px] py-[20px] narrow:grid-cols-1 narrow:gap-[8px] narrow:px-[14px] narrow:py-[14px] grid-cols-[170px_1fr]">
<span className="text-[12px] font-extrabold tracking-[0.06em] text-teal-deep uppercase narrow:text-[10px] narrow:tracking-[0.055em]">
{revenueFormulaCopy.tag}
</span>
<div className="text-[15px] leading-[1.55] text-white/[0.78] narrow:text-[12.2px] narrow:leading-[1.43] tiny:text-[11.7px] tiny:leading-[1.4] [&_strong]:font-extrabold [&_strong]:text-white narrow:[&_strong]:font-[780]">
<p className="narrow:text-[12.6px] narrow:leading-[1.35] tiny:text-[12px]">
<strong>{revenueFormulaCopy.headline}</strong>
</p>
<p className="mt-[6px] narrow:mt-[5px]">
{revenueFormulaCopy.sourcePrefix}
<strong>{revenueFormulaCopy.sourceAmount}</strong>
{revenueFormulaCopy.sourceSuffix}
</p>
<p className="mt-[6px] narrow:mt-[5px]">
<strong>{revenueFormulaCopy.caveat}</strong>
</p>
</div>
</div>
<div className="mb-[36px] grid grid-cols-3 gap-[18px] narrow:grid-cols-1">
{revenueScenarios.map((scenario) => (
<article
key={scenario.courses}
className="flex min-h-[190px] flex-col justify-between rounded-lg border border-teal/[0.26] bg-navy-4 px-[24px] py-[26px] text-white"
>
<div>
<strong className="block text-[clamp(34px,4vw,48px)] leading-none text-teal-bright">
{scenario.courses}
</strong>
<small className="text-white/[0.58]">курсов в месяц</small>
</div>
<b className="text-[clamp(24px,3vw,36px)]">{scenario.revenue}</b>
<small className="text-white/[0.58]">{scenario.note}</small>
</article>
))}
</div>
<RevenueCalculator />
<h3 className="mb-[16px] text-[18px]">Что превращает расчёт в окупаемость</h3>
<div className="grid grid-cols-4 gap-[12px] narrow:grid-cols-2">
{revenueDrivers.map((driver) => (
<div
key={driver}
className="rounded-[14px] border border-teal/[0.48] px-[14px] py-[18px] text-center text-[14px] font-bold"
>
{driver}
</div>
))}
</div>
<p className="mt-[28px] rounded-[14px] border border-teal/[0.2] bg-white/[0.035] px-[20px] py-[18px] text-[13px] leading-[1.5] text-white/[0.58]">
На финальном расчёте считаем CAPEX, ФОТ, площадь, аренду, расходники, налоги и маркетинг. Здесь показана рамка
выручки, чтобы быстро понять порядок потенциала.
</p>
<div
ref={cta.ref}
className={cx(
'revenue-cta-surface mt-[24px] flex items-center justify-between gap-[20px] rounded-md border border-teal/[0.24] px-[20px] py-[18px]',
'max-[700px]:flex-col max-[700px]:items-stretch',
cta.revealClass,
)}
>
<ButtonLink href="#proposal" className="min-w-[250px] shrink-0 max-[700px]:w-full max-[700px]:min-w-0">
Как получить выручку?
<ArrowRightIcon />
</ButtonLink>
<span className="max-w-[560px] text-[13px] leading-[1.5] text-white/[0.62] max-[700px]:text-center">
Перейдите к форме подготовим расчёт потенциала для вашего кабинета.
</span>
</div>
</Container>
</Section>
)
}
@@ -0,0 +1,36 @@
import logo from '../assets/images/exo-logo.png'
import { Container } from './Layout'
export function SiteFooter() {
return (
<footer
/* Extra bottom room on phones: `MobileActionBar` is ~76px tall plus the
safe-area inset, and it is still up at the foot of the page. */
className="bg-footer pt-[26px] pb-[calc(102px+env(safe-area-inset-bottom))] text-[12px] text-white/[0.52] md:pb-[26px]"
>
<Container className="grid gap-[14px] md:grid-cols-[220px_1fr]">
<div className="flex max-w-[180px] shrink-0 items-center">
<img
alt="ЭКЗО Групп — российские технологии реабилитации"
className="block h-auto w-[clamp(150px,16vw,180px)] max-w-[min(50vw,180px)] object-contain compact:w-[150px] compact:max-w-[48vw]"
src={logo}
width={900}
height={204}
/>
</div>
<div className="max-w-[900px] leading-[1.55]">
Материалы страницы предназначены для B2B-обсуждения проекта модернизации. Решение по сохранению, ремонту,
интеграции, замене или выводу оборудования принимается после технического и правового аудита. Конкретный
состав, медицинские показания, противопоказания, лицензирование, квалификация персонала, сроки и финансовая
модель определяются после сверки с актуальной документацией медицинских изделий.
</div>
<div className="flex flex-wrap justify-between gap-[10px] border-t border-white/[0.09] pt-[13px] md:col-span-full">
<span>© {new Date().getFullYear()} Экзо Групп</span>
<span>Российские технологии реабилитации</span>
</div>
</Container>
</footer>
)
}
@@ -0,0 +1,68 @@
import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content'
import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons'
import { Container } from './Layout'
/**
* The original ended up with the blurred background painted unconditionally —
* the last stylesheet override drops the scroll-dependent variant — so there is
* no scroll state to track here.
*
* Below 760px the bar carries the brand mark and the call button only. The two
* action pills used to be `shrink-0` and 288px wide inside a 343px container,
* which left the logo — the one element that has to stay legible on a
* credibility-led B2B page — 37px at iPhone width and 0px at 320px, with the
* CTA itself running past the right edge. The CTA moves to `MobileActionBar`,
* which has room for it and puts it in the thumb zone.
*/
export function SiteHeader() {
return (
<header className="fixed inset-x-0 top-0 z-[60] bg-navy-deep/[0.92] py-[14px] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]">
<Container className="flex items-center justify-between gap-[16px]">
<a className="inline-flex shrink-0 items-center" href="#top" aria-label="ЭКЗО Групп — наверх">
<img
alt="ЭКЗО Групп — российские технологии реабилитации"
className="block h-auto w-[146px] object-contain object-left compact:w-[138px] sm:w-[168px] lg:w-[clamp(172px,20vw,232px)]"
src={logo}
width={900}
height={204}
/>
</a>
<nav
aria-label="Основная навигация"
className="hidden items-center gap-[28px] text-[14px] font-[680] text-white/[0.76] lg:flex"
>
{navLinks.map((link) => (
<a key={link.href} href={link.href} className="transition-colors duration-200 hover:text-white">
{link.label}
</a>
))}
</nav>
<div className="flex shrink-0 items-center gap-[8px]">
<a
href={contacts.phoneHref}
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
className={buttonClass(
'call',
undefined,
/* Square and icon-only until the bar has room for the word. */
'min-h-[44px] w-[44px] px-0 text-[13px] md:w-auto md:px-[17px]',
)}
>
<PhoneIcon />
<span className="hidden md:inline">Звонок</span>
</a>
{/* `max-md`, not the `narrow` variant: `narrow` is max-width 760 and the
bar is min-width 760, so at exactly 760px both would hide and the page
would have no CTA at all. `max-md` is the exact complement of `md`. */}
<ButtonLink href="#proposal" size={headerSize} className="max-md:hidden">
Получить предложение
</ButtonLink>
</div>
</Container>
</header>
)
}
@@ -0,0 +1,84 @@
import productionSite from '../assets/images/production-site.webp'
import { companyStats, whyItems } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { whyIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
export function WhyExoSection() {
const photo = useReveal<HTMLDivElement>()
const list = useReveal<HTMLDivElement>()
const stats = useReveal<HTMLDivElement>()
return (
<Section id="why-exo">
<Container>
<SectionHead
eyebrow="Почему Экзо Групп"
title="Аудит, интеграция, производство и сопровождение — в одном контуре"
lead="Обычная поставка начинается с выбора аппарата. Экзо Групп начинает с действующего кабинета и отвечает за переход от разрозненного оборудования к работающему направлению."
/>
<div className="grid min-w-0 items-stretch gap-[24px] md:grid-cols-[minmax(360px,0.9fr)_minmax(0,1.1fr)]">
<div
ref={photo.ref}
className={cx(
'relative min-h-[390px] overflow-hidden rounded-xl shadow-deep',
"after:absolute after:inset-0 after:bg-[linear-gradient(0deg,rgb(3_24_39/0.68),transparent_56%)] after:content-['']",
photo.revealClass,
)}
>
<img
alt="Производственная площадка ЭКЗО Групп в Тольятти"
className="size-full object-cover"
loading="eager"
src={productionSite}
/>
<div className="absolute inset-x-[23px] bottom-[21px] z-1 text-white">
<strong className="block text-[22px]">Российское производство и сервис</strong>
<span className="block text-[13px] text-white/[0.67]">
Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».
</span>
</div>
</div>
<div className="min-w-0">
<div
ref={list.ref}
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[10px] md:grid-still', list.revealClass)}
>
{whyItems.map((item) => (
<div
key={item.title}
className="grid grid-cols-[44px_1fr] gap-[14px] rounded-[19px] border border-navy/[0.12] bg-white p-[18px]"
>
<span className="grid size-[44px] place-items-center rounded-[14px] bg-ice text-teal-ink [&>svg]:size-[22px]">
{whyIcons[item.icon]}
</span>
<div>
<strong className="mb-[4px] block text-[16px] text-navy">{item.title}</strong>
<span className="block text-[13px] text-muted">{item.text}</span>
</div>
</div>
))}
</div>
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] sm:grid-cols-4', stats.revealClass)}>
{companyStats.map((stat) => (
<div key={stat.label} className="rounded-md border border-navy/[0.12] bg-white p-[18px]">
<strong className="block text-[30px] leading-none tracking-[-0.045em] text-navy">{stat.value}</strong>
<span className="mt-[6px] block text-[12px] text-muted">{stat.label}</span>
</div>
))}
</div>
<SmallNote className="mt-[11px]">
Количественные показатели по корпоративным материалам Экзо Групп. Срок пилота ориентировочный и зависит
от помещения, лицензии, поставки и обучения.
</SmallNote>
</div>
</div>
</Container>
</Section>
)
}
+543
View File
@@ -0,0 +1,543 @@
import deviceHydrogen from '../assets/images/device-hydrogen.webp'
import deviceImpulse from '../assets/images/device-impulse.webp'
import deviceLaser from '../assets/images/device-laser.webp'
import deviceMagnet from '../assets/images/device-magnet.webp'
import devicePress from '../assets/images/device-press.webp'
import deviceTecar from '../assets/images/device-tecar.webp'
import deviceTherapy from '../assets/images/device-therapy.webp'
export const contacts = {
phone: '+7 939 717-80-80',
phoneHref: 'tel:+79397178080',
}
export const navLinks = [
{ href: '#market', label: 'Рынок' },
{ href: '#financial-scenarios', label: 'Экономика' },
{ href: '#technology', label: 'Оборудование' },
{ href: '#launch', label: 'Модели запуска' },
]
/* -------------------------------------------------------------------------- */
/* Первый экран */
/* -------------------------------------------------------------------------- */
export const heroFacts = [
{ value: '0 ₽', label: 'дополнительных расходов на маркетинг — рост за счёт текущей базы пациентов' },
{ value: '≈20 млн ₽', label: 'потенциальной выручки может уходить мимо клиники ежегодно' },
{ value: 'от 6 мес.', label: 'срок окупаемости' },
]
/** Disclaimer under the hero facts — they are a scenario, not a promise. */
export const heroFactsNote =
'Сценарные показатели. Финальная экономика рассчитывается после аудита кабинета, тарифов, загрузки и структуры курсов.'
/* -------------------------------------------------------------------------- */
/* Рыночный контекст */
/* -------------------------------------------------------------------------- */
export const marketStats = [
{
value: '2,41',
label: 'млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация',
source: 'WHO / IHME',
},
{ value: '+63%', label: 'рост глобальной потребности в реабилитации с 1990 года', source: 'WHO / IHME' },
{
value: '1,14 → 1,79',
label: 'млн пациентов получили медицинскую реабилитацию по ОМС в России, 20222025',
source: 'Минздрав России',
},
{
value: '+26,5%',
label: 'целевой рост числа получателей медицинской реабилитации к 2030 году',
source: 'Федеральный проект',
},
]
/** Пациенты медицинской реабилитации по ОМС, млн человек. */
export const trendBars = [
{ year: '2022', value: '1,14', height: '64%' },
{ year: '2023', value: '1,39', height: '78%' },
{ year: '2024', value: '1,52', height: '85%' },
{ year: '2025', value: '1,79', height: '100%' },
]
export const trendChartLabel =
'Рост числа пациентов медицинской реабилитации по ОМС в России с 1,14 миллиона в 2022 году до 1,79 миллиона в 2025 году'
export const cabinetAsProcedures = [
'аппараты работают разрозненно;',
'одна услуга за визит и слабая пакетизация;',
'назначения зависят от одного специалиста;',
'нет контроля загрузки и завершения курса.',
]
export const cabinetAsSystem = [
'существующие методы встроены в маршруты;',
'EXO закрывает только выявленные пробелы;',
'команда работает по единым картам и ролям;',
'руководитель видит загрузку, результат и экономику.',
]
export const marketSources = [
{
label: 'WHO / IHME',
href: 'https://www.who.int/teams/noncommunicable-diseases/sensory-functions-disability-and-rehabilitation/global-estimates-of-the-need-for-rehabilitation',
},
{
label: 'Минздрав России',
href: 'https://minzdrav.gov.ru/news/2026/04/06/30625-zamglavy-minzdrava-rossii-evgeniy-kamkin-rasskazal-kak-izmenilas-sistema-meditsinskoy-reabilitatsii-v-rossii',
},
{
label: 'федеральный проект медицинской реабилитации',
href: 'https://minzdrav.gov.ru/poleznye-resursy/natsionalnye-proekty-rossii-prodolzhitelnaya-i-aktivnaya-zhizn-novye-tehnologii-sberezheniya-zdorovya/struktura-i-klyuchevye-meropriyatiya-federalnogo-proekta-optimalnaya-dlya-vosstanovleniya-zdorovya-meditsinskaya-reabilitatsiya',
},
]
/* -------------------------------------------------------------------------- */
/* Экономика действующего кабинета */
/* -------------------------------------------------------------------------- */
export const leakStages = [
{ label: ['Не предложили', 'курс'], percent: '35%' },
{ label: ['Нет повторных', 'визитов'], percent: '30%' },
{ label: ['Нет регулярного', 'сопровождения'], percent: '25%' },
{ label: ['Нет комплексного', 'подхода'], percent: '20%' },
]
export const leakBreakdown = [
{ label: 'Не предложили курс (−35%)', value: '≈ 9 450 000 ₽' },
{ label: 'Нет повторных визитов (−30%)', value: '≈ 6 750 000 ₽' },
{ label: 'Нет регулярного сопровождения (−25%)', value: '≈ 2 970 000 ₽' },
{ label: 'Нет комплексного подхода (−20%)', value: '≈ 810 000 ₽' },
]
export const leakSolutions = [
{ icon: 'gear', title: 'Системная работа', text: 'Стандартизация процессов, назначений и скриптов.' },
{ icon: 'clipboard', title: 'Курсовой подход', text: 'План лечения оформляется как понятный курс.' },
{ icon: 'repeat', title: 'Повторные визиты', text: 'Контроль результата и плановое сопровождение пациента.' },
{ icon: 'layers', title: 'Комплексные программы', text: 'Комбинация действующих методик и оборудования ЭКЗО.' },
] as const
export const revenueFormula = [
{ value: '1 000', label: 'активных пациентов в базе за год' },
{ value: '27 000 ₽', label: 'средний чек курса' },
{ value: '4', label: 'цикла в год' },
]
export const revenueResult = {
value: '108 000 000 ₽',
label: 'дополнительной выручки в год без вложений в маркетинг',
}
export const revenueMetrics = [
{ value: '+108 000 000 ₽', label: 'дополнительная выручка в год' },
{ value: '4 цикла', label: 'повторного маршрута пациента в год в сценарной модели' },
{ value: '0 ₽', label: 'дополнительных расходов на маркетинг' },
{ value: 'от 6 мес.', label: 'срок окупаемости' },
]
/* -------------------------------------------------------------------------- */
/* Сценарии выручки */
/* -------------------------------------------------------------------------- */
export const revenueScenarios = [
{ courses: '30', revenue: '0,81 млн ₽', note: 'осторожный сценарий' },
{ courses: '60', revenue: '1,62 млн ₽', note: 'рабочая загрузка' },
{ courses: '100', revenue: '2,70 млн ₽', note: 'сильная загрузка' },
]
export const revenueDrivers = ['Загрузка кабинета', 'Доля курсов', 'Повторные визиты', 'Средний чек']
/**
* The formula band no longer carries a bare «без учёта расходов» aside: the
* average cheque now has to say where it comes from, so the aside became a
* three-paragraph column beside the tag.
*/
export const revenueFormulaCopy = {
tag: 'Формула расчёта',
headline: 'Оплаченные курсы × средний чек ≈ 27 000 ₽',
sourcePrefix: 'Средний чек курса составляет ',
sourceAmount: '27 000 ₽',
sourceSuffix: ', по данным нашей клиники «ЭкзоКлиник» в городе Тольятти за период 2025г.2026г.',
caveat: 'Средний чек сеанса может меняться в зависимости от региона и сезонных предложений.',
}
/* -------------------------------------------------------------------------- */
/* Калькулятор сценария */
/* -------------------------------------------------------------------------- */
export const calculatorCopy = {
eyebrow: 'Калькулятор сценария',
title: 'Посчитайте выручку и возврат инвестиций',
lead: 'Укажите загрузку, средний чек и объём вложений — калькулятор сразу покажет выручку, срок возврата и ROI.',
caption:
'выручка показывает сценарный потенциал продаж. Срок возврата и ROI рассчитаны до учёта ФОТ, аренды, расходников и налогов. Финальная экономика уточняется после аудита кабинета.',
}
/** Prefilled with the «рабочая загрузка» scenario from the cards above. */
export const calculatorFields = [
{
key: 'courses',
label: 'Оплаченных курсов в месяц',
hint: 'Сколько курсов кабинет продаёт за месяц.',
ariaLabel: 'Количество оплаченных курсов в месяц',
initial: 60,
step: 1,
},
{
key: 'averageCheck',
label: 'Средний чек курса, ₽',
hint: 'Средняя стоимость одного оплаченного курса.',
ariaLabel: 'Средний чек курса в рублях',
initial: 27_000,
step: 500,
},
{
key: 'investment',
label: 'Инвестиции в модернизацию, ₽',
hint: 'Вложения для расчёта срока возврата и ROI.',
ariaLabel: 'Инвестиции в модернизацию кабинета в рублях',
initial: 5_000_000,
step: 100_000,
},
] as const
export const calculatorResults = [
{ key: 'month', label: 'Дополнительная выручка / месяц', hint: 'до вычета расходов', accent: true },
{ key: 'year', label: 'Дополнительная выручка / год', hint: 'при выбранной загрузке', accent: false },
{ key: 'payback', label: 'Срок возврата инвестиций', hint: 'инвестиции ÷ выручка в месяц', accent: false },
{ key: 'roi', label: 'ROI за 12 месяцев', hint: 'сценарно, до операционных расходов', accent: false },
] as const
/* -------------------------------------------------------------------------- */
/* Модернизация */
/* -------------------------------------------------------------------------- */
export const promises = [
{
icon: 'load',
title: 'Загрузить действующее оборудование',
text: 'Вернуть полезным аппаратам точную роль, назначающих врачей, расписание и понятный курс.',
},
{
icon: 'gap',
title: 'Закрыть клинические пробелы',
text: 'Добавить только те EXO-модули, которых не хватает текущим врачебным маршрутам.',
},
{
icon: 'throughput',
title: 'Увеличить пропускную способность',
text: 'Развести активные и параллельные процедуры, сократить простои и собрать пакетные программы.',
},
{
icon: 'result',
title: 'Управлять результатом',
text: 'Измерять назначения, завершение курса, загрузку, динамику, повторные программы и экономику.',
},
] as const
export const profileChips = [
'Травматология и ортопедия',
'Неврология',
'Послеоперационная реабилитация',
'Спортивная медицина',
'Ревматология',
'Флебология / лимфология',
'Гинекология / Урология',
'Эстетическая медицина',
]
/* -------------------------------------------------------------------------- */
/* Технологический конструктор */
/* -------------------------------------------------------------------------- */
export const devices = [
{
image: deviceTherapy,
alt: 'Аппарат ЭкзоТерапия',
kicker: 'Системное усиление',
title: 'ЭкзоТерапия',
indications: 'ОДА • неврология • дыхательная система • сосуды • органы малого таза • ЖКТ',
role: 'Высокоинтенсивная импульсная магнитотерапия.',
},
{
image: deviceTecar,
alt: 'Аппарат ЭкзоТекар 3 в 1',
kicker: 'Локальное ядро',
title: 'ЭкзоТекар 3 в 1',
indications: 'мышцы • суставы • связки • сухожилия • фасции • рубцы • посттравматическое восстановление',
role: 'Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.',
},
{
image: deviceMagnet,
alt: 'Аппарат ЭкзоМагнит 3 в 1',
kicker: 'Многофакторный модуль',
title: 'ЭкзоМагнит 3 в 1',
indications: 'суставы • позвоночник • мышцы • сухожилия • мягкие ткани • малый таз',
role: 'Магнит высокой интенсивности + ударно-волновая терапия + инфракрасная терапия.',
},
{
image: devicePress,
alt: 'Аппарат ЭкзоПресс',
kicker: 'Параллельный поток',
title: 'ЭкзоПресс',
indications:
'отёчные программы • лимфоотток • снятие тяжести в ногах • сосуды • послеоперационная реабилитация',
role: 'Компрессионная терапия + тепловая терапия + холодовая терапия.',
},
{
image: deviceHydrogen,
alt: 'Аппарат ЭкзоВодород',
kicker: 'Поддерживающий контур',
title: 'ЭкзоВодород',
indications:
'системное восстановление • антиоксидантная терапия • улучшение метаболизма • поддержка иммунитета • антистресс',
role: 'Ингаляция молекулярным водородом',
},
{
image: deviceLaser,
alt: 'Аппарат ЭкзоЛазер B',
kicker: 'Локальные программы',
title: 'ЭкзоЛазер В',
indications:
'мягкотканные травмы • точечное воспаление • регенерация тканей • болевые синдромы • реабилитация • стимуляция микроциркуляции • противоотёчный эффект • лечение связок и сухожилий • ускорение метаболизма',
role: 'Высокоинтенсивная лазерная терапия.',
},
{
image: deviceImpulse,
alt: 'Аппарат ЭкзоИмпульс',
kicker: 'Нейромышечный модуль',
title: 'ЭкзоИмпульс',
indications:
'электромагнитная стимуляция • нервно-мышечная система • устранение спазмов и зажимов • активация регенерации • противовоспалительный эффект • микроциркуляция и лимфодренаж • снятие глубоких мышечных болей • восстановление двигательной активности • реабилитация при неврологических патологиях',
role: 'Фокусированная ударно-волновая терапия',
},
]
/* -------------------------------------------------------------------------- */
/* Комбинированные маршруты */
/* -------------------------------------------------------------------------- */
export const programs = [
{
num: '01',
title: 'Нейрореабилитация',
text: 'Наше оборудование помогает пациентам восстановить двигательные функции, в т.ч. после инсульта, снизить проявления спастичности и парезов, улучшить баланс и координацию.',
},
{
num: '02',
title: 'Травматология и ортопедия',
text: 'Устройства обеспечивают эффективную реабилитацию после переломов и эндопротезирования, способствуют снятию отёка и боли, повышают подвижность суставов и предупреждают развитие контрактур.',
},
{
num: '03',
title: 'Послеоперационное восстановление',
text: 'Оборудование позволяет активизировать пациентов в ранние сроки, стимулирует регенерацию тканей и снижает риск послеоперационных осложнений.',
},
{
num: '04',
title: 'Хронические боли и дегенеративные заболевания',
text: 'При остеохондрозе и артрозах оборудование помогает снизить болевой синдром и улучшить трофику и подвижность поражённых участков.',
},
{
num: '05',
title: 'Детская реабилитация',
text: 'Специальные методики способствуют развитию моторных навыков у детей с ДЦП, укрепляют мышечный корсет и помогают освоить вертикальное положение.',
},
{
num: '06',
title: 'Спортивная медицина',
text: 'Оборудование ускоряет восстановление спортсменов после травм и перегрузок, помогает грамотно вернуться к тренировкам и соревнованиям.',
},
{
num: '07',
title: 'Паллиативная поддержка',
text: 'Устройства облегчают хроническую боль и помогают сохранить функциональную активность на максимально возможном уровне.',
},
]
export const routeSteps = [
{
title: 'Запрос действующего пациента',
text: 'Боль, отёк, скованность, слабость или восстановление.',
},
{ title: 'Назначение врача', text: 'Выбор физического фактора и исключение противопоказаний.' },
{ title: 'Курс процедур', text: 'Действующие методы + EXO по этапу реабилитации.' },
{ title: 'Контроль реакции', text: 'Самочувствие, движение, переносимость и динамика жалоб.' },
{ title: 'Повтор / следующий этап', text: 'Поддерживающая программа или корректировка маршрута.' },
]
/* -------------------------------------------------------------------------- */
/* Модели усиления и пилот */
/* -------------------------------------------------------------------------- */
export const formats = [
{
area: 'от 12 м²',
title: 'Один кабинет',
text: 'Стартовый формат под отдельный сценарий и ограниченную конфигурацию.',
},
{
area: 'от 35 м²',
title: 'Два кабинета',
text: 'Базовая инфраструктура с разделением активных и параллельных процедур.',
},
{
area: 'от 45 м²',
title: 'Три кабинета',
text: 'Расширенный формат для нескольких маршрутов и большей пропускной способности.',
},
]
export const timeline = [
{
week: '12 неделя.',
title: 'Сбор данных и комплексный аудит кабинета',
lead: 'Мы проводим глубокий технический аудит текущего оборудования. Анализируем действующее направление по нозологиям, сроки лечения и методики.',
items: [
{
title: 'Инвентаризация и аудит оборудования',
text: 'Проверка технического состояния, износа и реальной загрузки каждого действующего аппарата клиники.',
},
{
title: 'Анализ расписания и логистики',
text: 'Поиск «узких мест» в сетке записи, из-за которых простаивают кабинеты и формируются простои у врачей.',
},
{
title: 'Экспертиза документов',
text: 'Проверка действующих медицинских лицензий, соответствия стандартам оснащения Минздрава и текущих карт назначений.',
},
{
title: 'Анализ штата и мотивации',
text: 'Оценка квалификации врачей и медсестёр, анализ их загрузки и текущей схемы оплаты труда.',
},
{
title: 'Оцифровка экономики и потока',
text: 'Расчёт стоимости одного визита пациента, маржинальности отдельных процедур и объёма упущенной прибыли клиники.',
},
],
},
{
week: '34 неделя.',
title: 'Разработка карты пробелов и проектирование EXO-маршрутов',
lead: 'На основе аудита мы создаём индивидуальное инженерное и медицинское решение под специфику вашей клиники.',
items: [
{ title: 'План по увеличению нагрузки на действующие аппараты' },
{ title: 'План по увеличению пропускной способности одного кабинета' },
{ title: 'Формирование индивидуальных комплексных методик' },
{ title: 'План перезонирования' },
],
},
{
week: '56 неделя.',
title: 'Бесшовное дооснащение и комплексное обучение команды',
lead: 'Мы интегрируем оборудование и готовим команду к новой модели работы без остановки текущего приёма пациентов. Все технические и образовательные процессы подстраиваются под график вашей клиники.',
items: [
{
title: 'Бесшовный монтаж и калибровка',
text: 'Установка модулей производится в удобное для вас время, включая выходные или вечерние часы. Клиника продолжает принимать пациентов в штатном режиме — вы не теряете ни одного дня выручки.',
},
{
title: 'Гибкий формат обучения персонала',
text: 'Мы подбираем комфортный график занятий без отрыва сотрудников от работы. Дополнительно доступна опция обучения на базе нашей действующей клиники.',
},
{
title: 'Внедрение технологических карт',
text: 'Интеграция готовых протоколов лечения в МИС клиники для быстрой работы врачей.',
},
{
title: 'Обучение врачей',
text: 'Глубокий курс по сочетанным методикам EXO-терапии, биомеханике, оценке динамики пациента и показаниям для курсового лечения.',
},
{
title: 'Инструктаж медицинских сестёр',
text: 'Практическая отработка навыков проведения параллельных процедур и контроля безопасности.',
},
{
title: 'Подготовка администраторов',
text: 'Обучение сервисного персонала работе со скриптами, алгоритмам удержания пациентов и правилам презентации комплексных пакетов.',
},
],
},
{
week: '78 неделя.',
title: 'Мягкий перезапуск, запуск пакетов и аналитика',
lead: 'Запуск обновлённого кабинета в работу, перевод текущих пациентов на новые маршруты и фиксация первых финансовых результатов.',
items: [
{
title: 'Работа с действующими пациентами',
text: 'Мягкий перевод пациентов с разовых процедур на новые комбинированные EXO-программы.',
},
{
title: 'Старт продаж новых пакетов услуг',
text: 'Внедрение высокомаржинальных курсовых продуктов вместо неэффективной поштучной продажи визитов.',
},
{
title: 'Внедрение контроля «до / после»',
text: 'Запуск системы объективной оценки результатов лечения: снимки, тесты и шкалы боли.',
},
{
title: 'Аналитика первой прибыли',
text: 'Оценка динамики загрузки кабинета, роста среднего чека и удовлетворённости пациентов за первые 14 дней работы.',
},
{
title: 'Дорожная карта по увеличению целевой аудитории',
text: 'План расширения спектра оказываемых услуг и дальнейшего развития кабинета.',
},
],
},
]
/* -------------------------------------------------------------------------- */
/* Почему Экзо Групп */
/* -------------------------------------------------------------------------- */
export const whyItems = [
{
icon: 'audit',
title: 'Аудит вместо типовой закупки',
text: 'Оцениваем парк, документы, загрузку, назначения и клинические пробелы до формирования комплектации.',
},
{
icon: 'clinic',
title: 'Собственная клиническая практика',
text: 'Оборудование, маршруты и организация работы проверяются внутри сети Экзо Клиник.',
},
{
icon: 'roles',
title: 'Обучение по ролям',
text: 'Врачи назначают, медсёстры выполняют, главный врач контролирует, администратор доводит пациента до курса.',
},
] as const
export const companyStats = [
{ value: '100+', label: 'точек установки' },
{ value: '60+', label: 'медицинских учреждений' },
{ value: '60 дней', label: 'до модели «до / после»' },
]
/* -------------------------------------------------------------------------- */
/* Заявка */
/* -------------------------------------------------------------------------- */
export const ctaChecks = [
'аудит действующего оборудования и решение по каждому аппарату;',
'EXO-конфигурация только под выявленные пробелы;',
'план пилота на 60 дней и KPI «до / после».',
]
/* -------------------------------------------------------------------------- */
/* Резидентские статусы */
/* -------------------------------------------------------------------------- */
export const residencies = [
{
href: 'https://isamara.ru/zhiguli-valley/',
label: 'Технопарк Жигулёвская долина',
width: 340,
height: 93,
},
{ href: 'https://sk.ru/', label: 'Участник проекта Сколково', width: 330, height: 88 },
]
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react'
/**
* Scroll-in animation, matching the original page: fires once at 12% visibility
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
* that `prefers-reduced-motion` can neutralise it in one place.
*/
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element || visible) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
setVisible(true)
observer.unobserve(entry.target)
}
},
{ threshold: 0.12 },
)
observer.observe(element)
return () => observer.disconnect()
}, [visible])
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
}
+396
View File
@@ -0,0 +1,396 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
Design tokens — ported 1:1 from the :root block of the original landing.
The default Tailwind palette and breakpoints are cleared so that only the
EXO design system is reachable from utility classes.
--------------------------------------------------------------------------- */
@theme {
--color-*: initial;
--color-transparent: transparent;
--color-current: currentColor;
--color-white: #ffffff;
--color-black: #000000;
/* Brand */
--color-navy: #0a2540;
--color-navy-deep: #061b2f;
--color-navy-3: #0e3352;
--color-navy-4: #0e3b5b;
--color-teal: #00c4b4;
--color-teal-bright: #45e4d7;
--color-teal-deep: #00a99a;
--color-teal-ink: #008f84;
--color-ice: #eafbf8;
/* Surfaces & text */
--color-paper: #f5fafb;
--color-ink: #0a2540;
--color-muted: #60778b;
--color-footer: #041524;
/* The dark "сценарии выручки" band sits between navy and navy-deep. */
--color-scenario: #071f35;
/* Radii */
--radius-md: 18px;
--radius-lg: 25px;
--radius-xl: 34px;
/* Elevation */
--shadow-deep: 0 30px 90px rgb(4 30 48 / 0.16);
--shadow-soft: 0 18px 55px rgb(3 26 43 / 0.08);
/* Layout rhythm */
--container-page: 1200px;
--spacing-section: clamp(68px, 8vw, 108px);
--spacing-section-compact: clamp(58px, 6vw, 84px);
/* Breakpoints — the original used 600 / 760 / 980 */
--breakpoint-*: initial;
--breakpoint-sm: 600px;
--breakpoint-md: 760px;
--breakpoint-lg: 980px;
/* "Inter Variable" is the family Fontsource registers; the rest of the chain
is the original stack, kept for the swap window and for the few glyphs
(≈ ×) that Inter's subsets do not carry. */
--font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif;
}
/* The original's `max-width` queries do not line up with the min-width scale
above; they are exposed as named variants instead of arbitrary values so the
intent stays readable in the markup. Declared widest-first: Tailwind emits
variants in registration order, so a narrower one must come later to win. */
@custom-variant stack (@media (max-width: 1050px));
@custom-variant narrow (@media (max-width: 760px));
@custom-variant compact (@media (max-width: 560px));
@custom-variant phone (@media (max-width: 520px));
@custom-variant tiny (@media (max-width: 430px));
@layer base {
html {
scroll-behavior: smooth;
background: var(--color-navy-deep);
}
body {
background-color: var(--color-paper);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* The fixed header is 78px tall; anchors must clear it. */
section[id] {
scroll-margin-top: 78px;
}
::selection {
background: rgb(0 196 180 / 0.28);
color: var(--color-navy);
}
/* Preflight zeroes the type scale; the original relied on these exact values
for every heading on the page, so they are restored once, here. */
h1,
h2,
h3 {
text-wrap: balance;
}
h1 {
margin-bottom: 22px;
max-width: 940px;
font-size: clamp(42px, 7.7vw, 84px);
font-weight: 790;
line-height: 0.98;
letter-spacing: -0.055em;
}
h2 {
margin-bottom: 20px;
font-size: clamp(34px, 5vw, 58px);
font-weight: 760;
line-height: 1.03;
letter-spacing: -0.045em;
}
h3 {
margin-bottom: 10px;
font-size: clamp(21px, 2.4vw, 28px);
font-weight: bold;
line-height: 1.14;
letter-spacing: -0.025em;
}
h4 {
font-weight: bold;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
}
@layer components {
/* Scroll-triggered entrance. A class rather than utilities because it is
applied to ~40 elements and toggled from a shared IntersectionObserver. */
.reveal {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.7s ease,
transform 0.7s cubic-bezier(0.2, 0.65, 0.3, 1);
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
/* Card rows that scroll horizontally on phones. Every section uses the same
mechanics and only differs in the card width and in the breakpoint where
the row becomes a plain grid — that part is applied per section with
`sm:grid-still` / `md:grid-still` and `auto-cols-*`. */
.scroll-cards {
display: grid;
grid-auto-flow: column;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
scrollbar-width: none;
padding: 2px 16px 15px;
margin-inline: -16px;
/* Without this the snapport starts at the scrollport edge, so `snap-align:
start` on the first card resolves to scrollLeft 16 — mandatory snapping
then forces that scroll on load and the first card sits on the bezel with
no gutter. Matching the inline padding puts the resting state at 0. */
scroll-padding-inline: 16px;
}
/* Card widths in these rows are written as `calc(100vw - 62px)` rather than
as a percentage of the track (16px gutter + 12px gap + a 34px sliver of the
next card). A percentage peek scales with the screen, and past ~40px it
starts exposing legible half-words, which reads as broken text rather than
as "there is more to the right". A fixed sliver looks the same on every
phone. The value stays in the markup as `auto-cols-*` so it keeps its sort
position against `sm:grid-still`, which has to win at the breakpoint. */
.scroll-cards::-webkit-scrollbar {
display: none;
}
.scroll-cards > * {
scroll-snap-align: start;
}
/* Hero backdrop: two stacked scrims. The horizontal one is pushed further
right from 980px up, where the layout gains room. */
.hero-scrim {
background:
linear-gradient(
90deg,
rgb(4 24 41 / 0.98) 0%,
rgb(5 28 47 / 0.92) 40%,
rgb(5 28 47 / 0.38) 76%,
rgb(5 28 47 / 0.14) 100%
),
linear-gradient(0deg, rgb(3 20 34 / 0.78), transparent 48%);
}
@media (min-width: 980px) {
.hero-scrim {
background:
linear-gradient(
90deg,
rgb(4 24 41 / 0.99) 0%,
rgb(5 28 47 / 0.93) 42%,
rgb(5 28 47 / 0.3) 74%,
rgb(5 28 47 / 0.1) 100%
),
linear-gradient(0deg, rgb(3 20 34 / 0.75), transparent 50%);
}
}
/* The hero photo, anchored to its right edge so that the logo printed on the
equipment stays clear of the copy at every width. */
.hero-photo {
background-position: 100% center;
background-size: cover;
}
@media (max-width: 980px) {
.hero-photo {
background-position: 96% center;
}
}
@media (max-width: 640px) {
.hero-photo {
background-position: 94% center;
}
}
/* Soft teal bloom used behind cards and panels, in four sizes/positions. */
.teal-bloom {
background: radial-gradient(circle, rgb(0 196 180 / 0.22), transparent 67%);
}
.teal-bloom-soft {
background: radial-gradient(circle, rgb(0 196 180 / 0.17), transparent 68%);
}
.gap-card-surface {
background: linear-gradient(145deg, var(--color-navy), #0c3650);
}
.system-panel-surface {
background: linear-gradient(145deg, #ffffff, #f2fbfa);
}
.equipment-card-surface {
background: linear-gradient(155deg, rgb(255 255 255 / 0.1), rgb(255 255 255 / 0.045));
}
.equipment-media-surface {
background: radial-gradient(circle at 50% 42%, rgb(0 196 180 / 0.15), rgb(3 22 38 / 0.16) 46%, rgb(3 22 38 / 0.64) 100%);
}
.loss-section-surface {
background: linear-gradient(180deg, #f8fcfd 0%, #eef8f8 100%);
}
.loss-panel-surface {
background: linear-gradient(145deg, #08253f 0%, #061b2f 100%);
}
.loss-anchor-surface {
background: linear-gradient(180deg, rgb(7 58 83 / 0.98), rgb(5 35 58 / 0.98));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
}
.loss-solution-surface {
background: linear-gradient(180deg, #ffffff, #f5fbfb);
}
.loss-result-surface {
background: linear-gradient(135deg, #00b9aa, #00cfc0);
}
/* The horizontal pipe the four leak stages sit on. Its metallic sheen is a
five-stop gradient, so it stays a class rather than an arbitrary value. */
.loss-pipe {
background: linear-gradient(180deg, #eff8fa 0%, #9fbbc4 45%, #dcecef 55%, #547986 100%);
box-shadow:
0 0 15px rgb(69 228 215 / 0.25),
inset 0 1px 1px rgb(255 255 255 / 0.8);
}
/* A drain hole with a droplet falling out of it. */
.loss-hole {
background: radial-gradient(
circle at 50% 42%,
#061b2f 0 34%,
#5f7e88 36% 48%,
#d9e9eb 50% 65%,
#6c8c96 67% 100%
);
box-shadow: 0 0 12px rgb(69 228 215 / 0.32);
}
.loss-hole::after {
content: "";
position: absolute;
left: 50%;
top: 36px;
width: 9px;
height: 14px;
transform: translateX(-50%) rotate(45deg);
border-radius: 65% 35% 65% 35%;
background: linear-gradient(135deg, #e9fbff, #6da7b7);
box-shadow: 0 5px 12px rgb(0 196 180 / 0.22);
}
.cta-surface {
background:
radial-gradient(circle at 20% 20%, rgb(0 196 180 / 0.18), transparent 33%),
radial-gradient(circle at 90% 100%, rgb(37 117 151 / 0.26), transparent 39%),
var(--color-navy-deep);
}
.revenue-cta-surface {
background: linear-gradient(135deg, rgb(0 196 180 / 0.1), rgb(255 255 255 / 0.035));
}
.residency-surface {
background: linear-gradient(180deg, #071d31 0%, #041524 100%);
}
/* Market trend bars: they grow once the surrounding reveal turns visible. */
.trend-bar {
background: linear-gradient(180deg, var(--color-teal-bright), var(--color-teal));
box-shadow: 0 10px 24px rgb(0 196 180 / 0.2);
transform: scaleY(0);
transform-origin: bottom;
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.is-visible .trend-bar {
transform: scaleY(1);
}
}
/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
breakpoint prefix, because sections switch over at different widths. */
@utility grid-still {
grid-auto-flow: row;
grid-auto-columns: auto;
overflow: visible;
padding: 0;
margin-inline: 0;
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* Honeypot: must stay focusable-but-invisible, so `hidden` is not an option. */
@utility honeypot {
position: absolute !important;
left: -9999px !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
.reveal {
opacity: 1;
transform: none;
}
.trend-bar {
transform: scaleY(1);
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cx(...values: (string | false | null | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
+80
View File
@@ -0,0 +1,80 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
const ENDPOINT = '/api/leads/medical-centers-existing-physio'
const DRAFT_KEY = 'exo_medcenter_lead_draft'
const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of utmKeys) {
const value = params.get(key)
if (value) utm[key] = value
}
return utm
}
export interface SubmitResult {
ok: boolean
error?: string
fields?: Record<string, string>
}
export async function submitLead(
form: LeadFormId,
values: Omit<LeadInput, 'form' | 'utm' | 'page' | 'referrer'>,
): Promise<SubmitResult> {
const payload: LeadInput = {
...values,
form,
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = (await response.json().catch(() => null)) as LeadResponse | null
if (!response.ok || !body?.ok) {
// The page must never lose a lead just because the CRM is down.
saveDraft(payload)
return {
ok: false,
error: body && !body.ok ? body.error : FALLBACK_ERROR,
fields: body && !body.ok ? body.fields : undefined,
}
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_lead_sent', form })
localStorage.removeItem(DRAFT_KEY)
return { ok: true }
} catch (error) {
saveDraft(payload)
console.warn('Lead endpoint error', error, payload)
return { ok: false, error: FALLBACK_ERROR }
}
}
function saveDraft(payload: LeadInput) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
} catch {
// Private-mode browsers throw on write; the visible error message is enough.
}
}
declare global {
interface Window {
dataLayer?: Record<string, unknown>[]
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Progressive `+7 900 000-00-00` mask, matching the original page's input
* handler. Returns the input untouched until the visitor has typed a digit, so
* that clearing the field is still possible.
*/
export function formatRuPhone(raw: string): string {
let digits = raw.replace(/\D/g, '').slice(0, 11)
if (!digits) return ''
if (digits[0] === '8') digits = `7${digits.slice(1)}`
if (digits[0] !== '7') digits = `7${digits}`
let value = '+7'
if (digits.length > 1) value += ` ${digits.slice(1, 4)}`
if (digits.length >= 5) value += ` ${digits.slice(4, 7)}`
if (digits.length >= 8) value += `-${digits.slice(7, 9)}`
if (digits.length >= 10) value += `-${digits.slice(9, 11)}`
return value
}
/** The original refused to submit before all eleven digits were entered. */
export const isCompleteRuPhone = (value: string) => value.replace(/\D/g, '').length >= 11
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Self-hosted so the landing stays self-contained and makes no third-party
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
// cyrillic are actually downloaded for this page.
import '@fontsource-variable/inter'
import App from './App'
import './index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root is missing from index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src", "shared"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo"
},
"include": ["server/src", "shared", "scripts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
// Set VITE_BASE_PATH=/medical-centers/with-physiotherapy/ when the site is
// mounted on a sub-path, so asset URLs in the built HTML resolve against it.
base: process.env.VITE_BASE_PATH ?? '/',
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist/client',
emptyOutDir: true,
assetsInlineLimit: 0,
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3002', changeOrigin: true },
},
},
})