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-no-physio-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:client"],
"port": 5175
}
]
}
+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/no-physiotherapy
# Sub-path the site is served from, if any. Leave as "/" when it sits at the
# domain root; set to "/medical-centers/no-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=3003
# ---------------------------------------------------------------------------
# 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-no-physio-landing
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.local
.DS_Store
+1
View File
@@ -0,0 +1 @@
24
+198
View File
@@ -0,0 +1,198 @@
# Физиотерапия с нуля — лендинг для медицинских центров
React-приложение лендинга MedCbezFiz (медицинский центр, где физиотерапии ещё
нет). Каждая заявка создаёт сделку в 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:5175` и API на `:3002`;
запросы `/api/*` проксируются с фронтенда на API.
Порты выбраны так, чтобы все лендинги можно было запускать одновременно:
`fitnes` и `medcenter` — 5173/3000, `hotel` — 5174/3001, этот — 5175/3002.
## Продакшен
```bash
npm run build # сборка клиента в dist/client
npm start # Express отдаёт dist/client и обрабатывает /api/*
```
Один процесс Node на любом VPS. Порт — `PORT` (по умолчанию 3002).
---
## 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 заранее не нужно.
Эндпоинт — `POST /api/leads/medical-centers-no-physio` (адрес взят из исходного
лендинга).
Если 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/продакшен-сервера (`3002`) |
| `VITE_SITE_URL` | нет | Канонический адрес, подставляется в `canonical` и `og:` |
| `VITE_BASE_PATH` | нет | Подпуть размещения, например `/medical-centers/no-physiotherapy/` (по умолчанию `/`) |
---
## Структура
```
index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
hooks/ появление секций при скролле, состояние шапки
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 исходный однофайловый лендинг (визуальный эталон)
legacy/README.md исходная инструкция к однофайловой версии
```
### Дизайн-система
Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в
`@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`.
Два места намеренно выбиваются из общей сетки, повторяя оригинал:
* блок «Сценарии выручки» (`.fin-model`) сохраняет отступ 32 px на всех
ширинах — в исходном CSS правило `.fin-model .container` перебивает общее
расширение контейнера до 48 px с 760 px;
* план запуска (`.timeline`) переходит в две колонки на 900 px, а не на 760 px,
как остальные секции.
### Шрифты
Inter подключён локально пакетом `@fontsource-variable/inter` — внешних запросов
(Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном
100–900, поэтому нестандартные веса из макета (720, 760, 790, 850) отрисовываются
по-настоящему, а не округляются до `bold`.
Символы `≈ ×` не входят в подсеты Inter у Fontsource, поэтому для них всегда
используется системный фолбэк. Это заметно только при очень крупном кегле.
`Manrope` и `Segoe UI` остались в стеке `--font-sans` как фолбэк, но отдельно не
загружаются.
### Экономика направления
Цифры блока «Сценарии выручки» — статические данные из `src/data/content.ts`,
как в исходном лендинге. Калькулятора на странице нет: расчёт делается на
аудите.
---
## Соответствие оригиналу
Текст страницы сверялся с `legacy/index.html` построчно: приложение
рендерилось в статический HTML, из обоих файлов извлекался видимый текст и
сравнивался. Совпадение полное — единственное расхождение — скрытая подпись
honeypot-поля, которой в оригинале нет.
### Что сделано иначе
* **Две фотографии аппаратов** (ЭкзоЛазер В и ЭкзоИмпульс) в исходном файле были
PNG на 1,5 МБ и 750 КБ — здесь они пережаты в webp, как и остальные снимки
оборудования. Исходники побайтово совпадают с медцентровским лендингом,
поэтому переиспользованы уже готовые webp-версии. Папка ассетов — 664 КБ
вместо 2,8 МБ.
* **Мёртвый CSS не переносился.** В оригинале лежат 39 правил
`.patient-economics` / `.base-economics-card`, на которые в разметке нет ни
одного элемента. Там же — обработчик анимации счётчиков, который ищет
`[data-counter]`; таких элементов на странице тоже нет.
* **Honeypot** получил визуально скрытую подпись — она нужна скринридерам и
скрыта утилитой `honeypot`, а не `display:none`, иначе поле перестанет быть
фокусируемым и ловушка не сработает.
### Что воспроизведено как есть
* Блок `.system-bottom` в секции «Готовое направление» сохраняет верхнюю
линию и отступы, хотя сетки карточек над ней в этом лендинге уже нет — так же
выглядит и оригинал.
* Мелкие подписи остались в исходных цветах: надзаголовки секций `#008F84` на
светлом фоне дают 3,79:1, источники цифр `#7E93A3` на белом — 3,19:1. Это
ниже 4,5:1, но соответствует оригиналу и остальным лендингам EXO; менять
палитру в одном лендинге не стали.
---
## Известные особенности
* `og:image` отдаётся по абсолютному адресу `VITE_SITE_URL` + `/og-image.webp`;
при смене домена обновите `VITE_SITE_URL` перед сборкой.
* Шапка прозрачна над первым экраном и получает размытую подложку после
18 px прокрутки — как в оригинале (в лендинге medcenter это правило было
перебито, и подложка там видна всегда).
+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="Запуск физиотерапии под ключ для медицинского центра, где направления пока нет: аудит потока, проект кабинета, технологии ЭКЗО, клинические маршруты, обучение и пилот за 90 дней."
/>
<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="Аудит, пространство, технологии, маршруты, обучение, запуск и контроль KPI — под существующий поток пациентов центра."
/>
<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>
+5
View File
@@ -0,0 +1,5 @@
MedCbezFiz — медицинский центр без физиотерапевтического кабинета
Готовый автономный лендинг.
Файл index.html содержит встроенные стили, JavaScript и изображения.
Для размещения загрузите index.html в нужную директорию сервера.
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
ГОТОВО К ЗАГРУЗКЕ НА СЕРВЕР
Загрузить вместе:
1. index.html
2. styles.css
3. script.js
4. папку assets целиком
После загрузки проверьте:
- открывается первый экран и фон;
- отображаются логотип и карточки оборудования;
- работает калькулятор;
- кнопки прокручивают к нужным блокам;
- форма отправляется в /api/leads/medical-centers-no-physio.
+17
View File
@@ -0,0 +1,17 @@
# EXO MedCbezFiz — серверная кодовая база
Актуальная версия лендинга медицинского центра без физиотерапевтического кабинета, собранная из текущего файла EXO_MedCbezFiz_Calculator_v3.html.
## Структура
- `index.html` — HTML-разметка
- `styles.css` — все стили лендинга
- `script.js` — интерактив, формы и калькулятор
- `assets/` — локальные изображения, логотипы, фоновые изображения и иконки
## Загрузка на сервер
Загрузите **всё содержимое этой папки целиком** в корневую директорию нужного URL/поддомена.
Не меняйте относительное расположение `index.html`, `styles.css`, `script.js` и папки `assets`.
## Важно по формам
В текущей версии сохранён endpoint формы: `/api/leads/medical-centers-no-physio`.
Если на сервере используется другой обработчик лидов, замените значение `FORM_ENDPOINT` в `script.js`.
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1 @@
<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: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long
+124
View File
@@ -0,0 +1,124 @@
const FORM_ENDPOINT = '/api/leads/medical-centers-no-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;
/* ---- next bundled script ---- */
(function(){
const root=document.querySelector('[data-course-calculator]'); if(!root)return;
const countInput=root.querySelector('#courseCalcCount'), checkInput=root.querySelector('#courseCalcCheck'), investmentInput=root.querySelector('#courseCalcInvestment');
const monthOutput=root.querySelector('[data-course-month]'), yearOutput=root.querySelector('[data-course-year]'), paybackOutput=root.querySelector('[data-course-payback]'), 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 updateCourseRevenue(){
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',updateCourseRevenue)); updateCourseRevenue();
})();
+609
View File
@@ -0,0 +1,609 @@
: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_14.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__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__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:23px}
.program-card p{margin:0;color:var(--muted);font-size:14px}
.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:start;padding:15px;border:1px solid rgba(255,255,255,.11);border-radius:17px;background:rgba(255,255,255,.06)}
.route-step__num{display:grid;place-items:center;width:42px;height:42px;padding:0;border-radius:13px;background:var(--teal);color:var(--navy);font-size:15px;font-weight:900;line-height:1;text-align:center}
.route-step strong{display:block;margin-bottom:2px;font-size:16px}
.route-step div>span{display:block;color:rgba(255,255,255,.63);font-size:13px}
.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}
.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}
}
/* 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 no-physio updates */
.launch-layout.launch-layout--single{grid-template-columns:1fr !important}
.timeline{grid-auto-columns:minmax(330px,92%) !important;gap:14px}
.timeline-step{min-height:auto !important;padding:22px 20px !important}
.timeline-step__week{display:block;margin-bottom:8px}
.timeline-step>strong{display:block;margin-bottom:10px;color:var(--navy);font-size:18px;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;color:var(--navy);font-size:13px}
@media(min-width:900px){.timeline{grid-auto-flow:initial !important;grid-auto-columns:auto !important;grid-template-columns:repeat(2,minmax(0,1fr)) !important;overflow:visible !important;padding:0 !important}}
.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}
/* final program cards typography/layout fix */
.program-track{grid-auto-columns:minmax(300px,88%) !important;gap:14px !important}
.program-card{min-height:290px !important;padding:22px !important;overflow:hidden !important}
.program-card__num{margin-bottom:14px !important}
.program-card h3{font-size:clamp(18px,1.7vw,22px) !important;line-height:1.12 !important;overflow-wrap:anywhere !important;word-break:normal !important;hyphens:auto !important;margin-bottom:12px !important}
.program-card p{font-size:13px !important;line-height:1.48 !important;overflow-wrap:anywhere !important;word-break:normal !important;hyphens:auto !important}
@media(min-width:600px){
.program-track{grid-auto-columns:minmax(320px,48%) !important}
}
@media(min-width:900px){
.program-track{grid-auto-flow:initial !important;grid-template-columns:repeat(2,minmax(0,1fr)) !important;overflow:visible !important;padding:0 !important;margin:0 !important}
.program-card{min-height:260px !important}
}
@media(min-width:1180px){
.program-track{grid-template-columns:repeat(3,minmax(0,1fr)) !important}
.program-card{min-height:275px !important}
.program-card:last-child{grid-column:auto !important}
}
/* call button */
.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}
@media(max-width:520px){.header__actions{gap:6px}.btn--call{padding-inline:12px}.btn--call span{display:none}.btn--call svg{width:18px;height:18px}}
/* 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-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}}
/* Current-base economics visual */
.base-economics-visual{padding:clamp(34px,5vw,64px) 0;background:#F5FAFB}
.base-economics-card{padding:clamp(12px,2vw,20px);border:1px solid rgba(10,37,64,.10);border-radius:24px;background:#fff;box-shadow:0 22px 70px rgba(3,26,43,.10)}
.base-economics-card img{display:block;width:100%;height:auto;max-height:760px;object-fit:contain;border-radius:16px}
@media(max-width:760px){.base-economics-visual{padding:28px 0}.base-economics-card{padding:8px;border-radius:18px}.base-economics-card img{border-radius:12px}}
/* v4 — полноценный блок экономики текущей базы */
.patient-economics{padding:clamp(72px,8vw,108px) 0;background:#f5fafb;color:var(--navy)}
.patient-economics__head{max-width:900px;margin-bottom:34px}
.patient-economics__head h2{max-width:820px;margin-bottom:10px;font-size:clamp(32px,4vw,52px);line-height:1.02;letter-spacing:-.04em}
.patient-economics__head>p:last-child{max-width:800px;margin:0;color:var(--muted);font-size:17px}
.patient-economics__grid{display:grid;grid-template-columns:minmax(240px,320px) 1fr;gap:34px;align-items:stretch}
.patient-economics__hero{position:relative;min-height:420px;padding:28px 28px 48px;border-radius:22px;background:#0a2b49;color:#fff;box-shadow:0 24px 60px rgba(6,27,47,.14)}
.patient-economics__hero h3{margin:0 0 10px;font-size:27px;line-height:1.05}
.patient-economics__x5{margin:12px 0 8px;font-size:54px;font-weight:850;line-height:1;color:#fff;letter-spacing:-.05em}
.patient-economics__hero>strong{display:block;margin-bottom:22px;font-size:17px;line-height:1.22}
.patient-economics__hero ul{margin:0;padding-left:18px;color:rgba(255,255,255,.86);font-size:13px;line-height:1.5}
.patient-economics__hero-note{position:absolute;left:24px;right:24px;bottom:-18px;padding:12px 14px;border-radius:9px;background:var(--teal);color:var(--navy);font-size:12px;font-weight:800;text-align:center}
.patient-economics__content{min-width:0;padding-top:2px}
.patient-economics__metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:28px}
.patient-economics__metrics article{min-height:92px;padding:18px 14px;border:1px solid rgba(0,196,180,.34);border-radius:13px;background:#fff;display:flex;flex-direction:column;justify-content:center;text-align:center}
.patient-economics__metrics strong{display:block;color:#00a99b;font-size:20px;line-height:1.05;white-space:normal}
.patient-economics__metrics span{margin-top:8px;color:var(--muted);font-size:10px;font-weight:650;line-height:1.2}
.patient-economics__subtitle{margin:0 0 15px;font-size:19px;letter-spacing:-.02em}
.patient-economics__steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
.patient-economics__steps article{min-height:150px;padding:15px;border:1px solid rgba(0,196,180,.34);border-radius:12px;background:#fff}
.patient-economics__steps article.is-accent{background:rgba(0,196,180,.10)}
.patient-economics__steps span{display:block;margin-bottom:16px;color:#00aa9c;font-size:10px;font-weight:850}
.patient-economics__steps h4{margin:0 0 8px;font-size:15px;line-height:1.08}
.patient-economics__steps p{margin:0;color:var(--muted);font-size:11px;line-height:1.35}
.patient-economics__result{position:relative;overflow:hidden;margin-top:24px;padding:22px 24px 20px;border:1px solid rgba(57,224,210,.28);border-radius:20px;background:linear-gradient(135deg,#082943 0%,#0d3a59 58%,#0a304b 100%);box-shadow:0 22px 55px rgba(6,27,47,.18),inset 0 1px 0 rgba(255,255,255,.05);color:#fff;text-align:left}
.patient-economics__result:before{content:"";position:absolute;inset:0 auto 0 0;width:4px;background:linear-gradient(180deg,var(--teal),#39E0D2);box-shadow:0 0 24px rgba(0,196,180,.48)}
.patient-economics__result:after{content:"";position:absolute;width:220px;height:220px;right:-95px;top:-130px;border-radius:50%;background:radial-gradient(circle,rgba(57,224,210,.14),rgba(57,224,210,0) 70%);pointer-events:none}
.patient-economics__result-kicker{position:relative;z-index:1;margin:0 0 14px;color:#64e7dc;font-size:10px;font-weight:850;letter-spacing:.12em;text-transform:uppercase}
.patient-economics__equation{position:relative;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr) auto minmax(0,1.2fr);gap:13px;align-items:center}
.patient-economics__metric{min-width:0;padding:13px 14px;border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(255,255,255,.055)}
.patient-economics__metric strong{display:block;margin:0 0 5px;color:#fff;font-size:clamp(20px,2.3vw,29px);line-height:1;font-weight:820;letter-spacing:-.035em}
.patient-economics__metric span{display:block;color:rgba(255,255,255,.58);font-size:10.5px;line-height:1.25}
.patient-economics__metric--accent{border-color:rgba(57,224,210,.50);background:linear-gradient(135deg,rgba(0,196,180,.18),rgba(57,224,210,.08));box-shadow:inset 0 0 0 1px rgba(57,224,210,.05)}
.patient-economics__metric--accent strong{color:#5be5d9}
.patient-economics__operator{color:rgba(255,255,255,.48);font-size:22px;font-weight:700;text-align:center}
.patient-economics__operator--equals{color:#5be5d9}
.patient-economics__result-note{position:relative;z-index:1;margin:14px 0 0;padding-top:13px;border-top:1px solid rgba(255,255,255,.09);color:rgba(255,255,255,.60);font-size:11px;line-height:1.45}
@media(max-width:980px){.patient-economics__grid{grid-template-columns:1fr}.patient-economics__hero{min-height:0;padding-bottom:58px}.patient-economics__metrics,.patient-economics__steps{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.patient-economics{padding:64px 0}.patient-economics__head h2{font-size:34px}.patient-economics__metrics,.patient-economics__steps{grid-template-columns:1fr 1fr;gap:9px}.patient-economics__metrics article{min-height:84px;padding:14px 8px}.patient-economics__metrics strong{font-size:17px}.patient-economics__steps article{min-height:145px;padding:13px}.patient-economics__result{padding:18px 16px 17px;border-radius:16px}.patient-economics__equation{grid-template-columns:1fr;gap:8px}.patient-economics__operator{font-size:16px;line-height:1}.patient-economics__metric{padding:12px 13px}.patient-economics__metric strong{font-size:22px}.patient-economics__result-note{font-size:10.5px}}
@media(max-width:430px){.patient-economics__metrics,.patient-economics__steps{grid-template-columns:1fr}.patient-economics__steps article{min-height:auto}.patient-economics__hero{border-radius:18px}}
/* Revenue scenarios CTA */
.fin-cta-wrap{display:flex;justify-content:center;margin-top:26px}
.fin-cta{min-width:270px}
@media(max-width:560px){.fin-cta-wrap{margin-top:22px}.fin-cta{width:100%;min-width:0}}
/* Revenue calculator — added to the existing financial scenarios block */
.fin-calculator{
margin:0 0 32px;padding:clamp(20px,2.5vw,28px);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:18px}
.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.2vw,29px);line-height:1.1;letter-spacing:-.025em}
.fin-calculator__head p{max-width:420px;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:48px;padding:0 13px;border:1px solid rgba(57,224,210,.42);border-radius:11px;outline:0;background:#fff;color:#071f35;font-size:clamp(18px,2vw,24px);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:108px;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:10px;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(20px,2.2vw,29px);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:17px;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:98px}.fin-calculator__result strong{font-size:clamp(23px,8vw,30px)}}
+3661
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "exo-medcenter-no-physio-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"
}
}
+2178
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<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: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+66
View File
@@ -0,0 +1,66 @@
/**
* 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',
'profile',
'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') ?? 3003,
// 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-no-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)',
)
})
+149
View File
@@ -0,0 +1,149 @@
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: 'Телефон',
profile: 'Основные направления центра',
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: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
},
profile: {
codes: ['PROFILE', 'SPECIALITY'],
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,
profile: payload.profile,
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),
}
}
}
+43
View File
@@ -0,0 +1,43 @@
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),
/** «Основные направления центра» — one of the select's options, or free text. */
profile: 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> }
+38
View File
@@ -0,0 +1,38 @@
import { EquipmentSection } from './components/EquipmentSection'
import { Hero } from './components/Hero'
import { LaunchSection } from './components/LaunchSection'
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 { ThumbBar } from './components/ThumbBar'
import { WhyExoSection } from './components/WhyExoSection'
export default function App() {
return (
<>
<SiteHeader />
<main>
<Hero />
<MarketSection />
<ScenariosSection />
<ProjectSection />
<ProgramsSection />
<EquipmentSection />
<LaunchSection />
<WhyExoSection />
<RequestSection />
</main>
<ResidencySection />
<SiteFooter />
<ThumbBar />
</>
)
}
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

+79
View File
@@ -0,0 +1,79 @@
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 ' +
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
'[&>svg]:size-[18px] [&>svg]:shrink-0'
/**
* `whitespace-nowrap` belongs on buttons sized by their label, not on every
* button. A full-width button whose label cannot wrap reports a min-content
* width of its whole label — 358px for «Получить предварительный аудит» — and
* that width propagates out through any `auto` grid track around it. That is
* what pushed the whole `#proposal` section 47px off a 375px screen and let
* `overflow-hidden` slice the copy and the form fields off the right edge.
*/
export const nowrap = 'whitespace-nowrap'
/**
* 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] whitespace-nowrap 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,98 @@
import { devices, equipmentNote } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, Section, SectionHead } from './Layout'
import { Rail } from './Rail'
/**
* Product shots differ in framing, so the last stylesheet pass in the original
* sized two 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%] max-[720px]:max-w-[86%] max-[720px]:max-h-[86%]',
}
const defaultFit = 'max-w-[86%] max-h-[86%] translate-y-[8px]'
/** The laser shot is padded evenly instead of being nudged up off the base. */
const mediaBox: Record<string, string> = {
'ЭкзоЛазер В': 'p-[20px]',
}
const defaultBox = 'px-[18px] pt-[18px] pb-[14px]'
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="Состав под профиль центра и клинические маршруты"
lead="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования."
/>
<Rail
outerRef={track.ref}
wrapperClassName={track.revealClass}
tone="dark"
label="Оборудование Экзо Групп"
className="scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3"
>
{devices.map((device) => (
<article
key={device.title}
className="equipment-card-surface flex min-h-[430px] flex-col overflow-hidden rounded-[26px] border border-white/[0.13] md:min-h-[425px]"
>
<div
className={cx(
'equipment-media-surface flex h-[300px] items-center justify-center overflow-hidden',
mediaBox[device.title] ?? defaultBox,
)}
>
<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-[18px] text-[14px] text-white/[0.66]">{device.indications}</p>
<div className="mt-auto border-t border-white/[0.12] pt-[15px] text-[13px] font-[720] text-[#C7FFF9]">
{device.role}
</div>
</div>
</article>
))}
</Rail>
<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>{equipmentNote}</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>
)
}
+89
View File
@@ -0,0 +1,89 @@
import heroBg from '../assets/images/hero-bg.webp'
import { heroFacts } 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
compact:min-h-[660px] compact:pt-[132px] compact:pb-[36px] 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="hero-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] compact:mb-[22px] compact:text-[17px]">
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
клинические маршруты, обучаем команду.
</p>
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] compact:mb-[26px] sm:flex-row sm:items-center">
<ButtonLink href="#proposal">
Получить предварительный аудит
<ArrowRightIcon />
</ButtonLink>
</div>
{/* Two specs and a claim, not three facts. On a phone the third one
stops being an orphan on its own row and starts being the point:
it is the only number here about money. */}
<div
aria-label="Ключевые параметры проекта"
className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:gap-[9px] phone:[&>*:last-child]:col-span-full"
>
{heroFacts.map((fact, index) => {
const claim = index === heroFacts.length - 1
return (
<div
key={fact.value}
className={cx(
'min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]',
'phone:min-h-0 phone:px-[14px] phone:py-[13px]',
claim && 'phone:border-teal/[0.42] phone:bg-teal/[0.11]',
)}
>
<strong
className={cx(
'mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]',
claim && 'phone:text-[#9FF6EC]',
)}
>
{fact.value}
</strong>
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
</div>
)
})}
</div>
<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>
)
}
+105
View File
@@ -0,0 +1,105 @@
import type { ReactNode } from 'react'
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
function Icon({ className, children }: { className?: string; children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className={className}>
{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 ChevronUpIcon({ className }: { className?: string }) {
return (
<Icon className={className}>
<path d="m6 15 6-6 6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function CloseIcon({ className }: { className?: string }) {
return (
<Icon className={className}>
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeLinecap="round" strokeWidth="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>
)
}
/** Icons of the four promise cards, keyed by `promises[].icon`. */
export const promiseIcons = {
retain: (
<Icon>
<path d="M4 19V7l8-4 8 4v12H4Z M8 19v-6h8v6" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
revenue: (
<Icon>
<path d="M4 17 9 12l4 4 7-9 M20 7v5h-5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
clinical: (
<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>
),
metrics: (
<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 = {
production: (
<Icon>
<path d="M4 17V7l8-4 8 4v10l-8 4-8-4Z M9 9h6v6H9z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
clinics: (
<Icon>
<path d="M5 20v-8M12 20V4M19 20v-5M3 20h18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
),
shield: (
<Icon>
<path
d="M8 12l3 3 5-6M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</Icon>
),
}
@@ -0,0 +1,145 @@
import { useState } from 'react'
import { formatNote, formats, timeline } from '../data/content'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ChevronUpIcon } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function LaunchSection() {
const grid = useReveal<HTMLDivElement>()
const steps = useReveal<HTMLDivElement>()
const stacked = useMediaQuery('(max-width: 899px)')
return (
<Section id="launch" tone="white">
<Container>
<SectionHead
split
eyebrow="Пространство и запуск"
title="Начать можно с одного кабинета и расширяться после подтверждения KPI"
lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы."
/>
<Rail
outerRef={grid.ref}
wrapperClassName={grid.revealClass}
label="Форматы кабинета"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3"
>
{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>
))}
</Rail>
<SmallNote className="mt-[16px]">{formatNote}</SmallNote>
{/* Twelve weeks in order — deliberately not a rail. Sideways scrolling
would hide week 35 from someone still reading week 12, and the
order is the whole point of this block. Below 900px the four weeks
stack along a teal spine and open one at a time: the sequence stays
visible at a glance, and the eighteen bullets underneath it stop
costing 2400px of scroll to walk past. */}
<div
ref={steps.ref}
className={cx(
'mt-[30px] grid grid-cols-[minmax(0,1fr)] gap-[14px]',
'relative pl-[26px] before:absolute before:top-[16px] before:bottom-[16px] before:left-[5px] before:w-[2px]',
"before:rounded-full before:bg-[linear-gradient(180deg,var(--color-teal),rgb(0_196_180/0.14))] before:content-['']",
'min-[900px]:grid-cols-2 min-[900px]:gap-[14px] min-[900px]:pl-0 min-[900px]:before:hidden',
steps.revealClass,
)}
>
{timeline.map((step, index) => (
<TimelineStep key={step.week} step={step} defaultOpen={index === 0} collapsible={stacked} />
))}
</div>
</Container>
</Section>
)
}
/**
* One week of the launch plan. A disclosure below 900px, a plain card above it
* — the difference is real markup, not a class, because a heading that is a
* button on a phone should not stay a button on a desktop where nothing
* collapses.
*/
function TimelineStep({
step,
defaultOpen,
collapsible,
}: {
step: (typeof timeline)[number]
defaultOpen: boolean
collapsible: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const expanded = !collapsible || open
const week = (
<span className="block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">{step.week}</span>
)
const title = <strong className="mt-[6px] block text-[17px] leading-[1.2] text-navy">{step.title}</strong>
return (
<article
className="relative rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[20px]
before:absolute before:top-[26px] before:-left-[26px] before:size-[12px] before:rounded-full before:border-[3px]
before:border-white before:bg-teal before:content-[''] min-[900px]:py-[22px] min-[900px]:before:hidden"
>
{collapsible ? (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex w-full cursor-pointer items-start gap-[12px] text-left outline-none focus-visible:rounded-[6px] focus-visible:ring-2 focus-visible:ring-teal"
>
<span className="min-w-0 flex-1">
{week}
{title}
</span>
<ChevronUpIcon
className={cx(
'mt-[3px] size-[18px] shrink-0 text-teal-ink transition-transform duration-250',
open ? 'rotate-0' : 'rotate-180',
)}
/>
</button>
) : (
<>
{week}
{title}
</>
)}
{expanded ? (
<>
<span className="mt-[12px] 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>
{item.text ? `: ${item.text}` : null}
</li>
))}
</ul>
</>
) : null}
</article>
)
}
+153
View File
@@ -0,0 +1,153 @@
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>
)
}
+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(
'min-w-0 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,127 @@
import { marketNotes, marketStats, routeWith, routeWithout } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function MarketSection() {
const stats = useReveal<HTMLDivElement>()
const gap = useReveal<HTMLElement>()
return (
<Section id="market">
<Container>
<SectionHead
split
eyebrow="Рыночный контекст"
title="Спрос на реабилитацию растёт. Пациент уже находится внутри вашего центра"
lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли."
/>
<div className="grid grid-cols-[minmax(0,1fr)] gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
<Rail
outerRef={stats.ref}
wrapperClassName={stats.revealClass}
label="Рыночные показатели"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2"
>
{marketStats.map((stat) => (
/* The source line used to be absolutely positioned against the
card's min-height, so the longest label ran underneath it.
It is in the flow now, pinned to the bottom by `mt-auto`. */
<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-[168px] 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 mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
{stat.value}
</strong>
<p className="relative max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
<span className="relative mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
{stat.source}
</span>
</article>
))}
</Rail>
<article
ref={gap.ref}
className={cx(
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep compact:p-[20px]',
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)] compact:text-[22px] compact:tracking-[-0.02em]">
Маршрут пациента: как перестать отдавать прибыль конкурентам.
</h3>
</div>
{/* Not `md:` — at 760 the card is already sharing the row with the
stats, so splitting it again gives two 165px columns of
four-word lines. It waits for 980, where the card is wide. */}
<div className="relative z-1 grid gap-[12px] lg:grid-cols-2">
<RouteColumn sign="—" title="Пока физиотерапии нет" items={routeWithout} />
<RouteColumn sign="+" title="После запуска направления" items={routeWith} accent />
</div>
</article>
</div>
<SmallNote className="mt-[16px]">{marketNotes[0]}</SmallNote>
<SmallNote className="mt-[8px]">{marketNotes[1]}</SmallNote>
</Container>
</Section>
)
}
function RouteColumn({
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,81 @@
import { programs, routeSteps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionHead } from './Layout'
import { Rail } from './Rail'
export function ProgramsSection() {
const route = useReveal<HTMLElement>()
return (
<Section id="programs" compact>
<Container>
<SectionHead
eyebrow="Курсовая модель"
title="Новая услуга сразу запускается как маршрут, а не набор разовых процедур"
lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель."
/>
<Rail
label="Клинические программы"
className="scroll-cards auto-cols-[minmax(300px,88%)] gap-[14px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5"
>
{programs.map((program) => (
<ProgramCard key={program.num} {...program} />
))}
</Rail>
<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-start 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="grid size-[42px] place-items-center rounded-[13px] bg-teal text-[15px] leading-none font-black text-navy md:mb-[12px]">
{index + 1}
</span>
<div>
<strong className="mb-[2px] block text-[16px]">{step.title}</strong>
<span className="block text-[13px] leading-[1.42] text-white/[0.63]">{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-[290px] overflow-hidden rounded-[22px] border border-navy/[0.12] bg-white p-[22px] shadow-[0_15px_45px_rgb(4_30_48/0.055)] md:min-h-[255px]',
revealClass,
)}
>
<span className="mb-[14px] block text-[13px] font-black tracking-[0.12em] text-[#00A99C]">{num}</span>
<h3 className="mb-[12px] text-[clamp(18px,1.7vw,22px)] leading-[1.12] [overflow-wrap:anywhere] [word-break:normal] [hyphens:auto]">
{title}
</h3>
<p className="text-[13px] leading-[1.48] text-muted [overflow-wrap:anywhere] [word-break:normal] [hyphens:auto]">
{text}
</p>
</article>
)
}
@@ -0,0 +1,75 @@
import { profileChips, profileChipsNote, promises, systemStatement } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { promiseIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function ProjectSection() {
const strip = useReveal<HTMLDivElement>()
const panel = useReveal<HTMLDivElement>()
return (
<Section id="project" tone="white">
<Container>
<SectionHead
eyebrow="Готовое направление"
title="Не просто поставка оборудования, а запуск полноценного отделения физиотерапии под ключ"
lead={
<>
Каждый шаг нашей дорожной карты нужен для того, чтобы кабинет приносил реальные деньги, а не просто
числился на балансе клиники. «<strong>Экзо</strong> Групп» комплексно решает задачи медицинского и
операционного запуска.
</>
}
/>
<Rail
outerRef={strip.ref}
wrapperClassName={cx('mb-[24px] sm:mb-[28px]', strip.revealClass)}
label="Что получает центр"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2 md:grid-cols-4"
>
{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>
))}
</Rail>
<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,
)}
>
{/* `.system-bottom` keeps its rule and spacing even though this page
dropped the card grid that used to sit above it. */}
<div className="mt-[24px] grid items-center gap-[18px] border-t border-navy/[0.12] pt-[24px] 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]">
{systemStatement}
</div>
<div>
<SmallNote className="mb-[10px]">{profileChipsNote}</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>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { cx } from '../lib/cx'
/**
* A row of peer cards: scrolls sideways on phones, becomes a plain grid at the
* breakpoint the caller sets in `className` (`sm:grid-still` / `md:grid-still`).
*
* Only rows whose cards are unordered peers belong here. Sequences — the launch
* timeline, the patient route — stay stacked, because sideways scrolling hides
* step two from someone still reading step one.
*
* The segmented indicator underneath measures the row instead of guessing from
* a breakpoint, so it appears exactly when the sideways gesture exists and
* disappears the moment the row turns back into a grid.
*/
export function Rail({
label,
tone = 'light',
className,
wrapperClassName,
outerRef,
children,
}: {
label?: string
tone?: 'light' | 'dark'
/** Classes for the scrolling row itself. */
className?: string
/** Classes for the wrapper that holds the row and the indicator. */
wrapperClassName?: string
outerRef?: RefObject<HTMLDivElement | null>
children: ReactNode
}) {
const rowRef = useRef<HTMLDivElement>(null)
const [{ index, count }, setPosition] = useState({ index: 0, count: 0 })
useEffect(() => {
const row = rowRef.current
if (!row) return
const measure = () => {
/* `count: 0` hides the indicator — the row is a grid at this width. */
if (row.scrollWidth - row.clientWidth < 4) {
setPosition((current) => (current.count === 0 ? current : { index: 0, count: 0 }))
return
}
const cards = Array.from(row.children) as HTMLElement[]
const middle = row.getBoundingClientRect().left + row.clientWidth / 2
let nearest = 0
let shortest = Infinity
cards.forEach((card, position) => {
const box = card.getBoundingClientRect()
const distance = Math.abs(box.left + box.width / 2 - middle)
if (distance < shortest) {
shortest = distance
nearest = position
}
})
setPosition((current) =>
current.index === nearest && current.count === cards.length
? current
: { index: nearest, count: cards.length },
)
}
measure()
row.addEventListener('scroll', measure, { passive: true })
const observer = new ResizeObserver(measure)
observer.observe(row)
return () => {
row.removeEventListener('scroll', measure)
observer.disconnect()
}
}, [])
const goTo = (position: number) => {
const row = rowRef.current
const card = row?.children[position] as HTMLElement | undefined
if (!row || !card) return
/* 16px is the row's own inline padding — the resting offset of card one. */
row.scrollBy({
left: card.getBoundingClientRect().left - row.getBoundingClientRect().left - 16,
behavior: 'smooth',
})
}
return (
<div ref={outerRef} className={wrapperClassName}>
<div ref={rowRef} aria-label={label} className={className}>
{children}
</div>
{count > 1 ? (
<div className="mt-[2px] flex items-center gap-[7px]">
{Array.from({ length: count }, (_, position) => (
<button
key={position}
type="button"
aria-label={`Показать карточку ${position + 1} из ${count}`}
aria-current={position === index}
onClick={() => goTo(position)}
className="flex h-[22px] cursor-pointer items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-teal"
>
<span
className={cx(
'block h-[4px] rounded-full transition-[width,background-color] duration-300',
position === index ? 'w-[28px]' : 'w-[10px]',
tone === 'dark'
? position === index
? 'bg-teal-bright'
: 'bg-white/[0.22]'
: position === index
? 'bg-teal'
: 'bg-navy/[0.16]',
)}
/>
</button>
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,44 @@
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">
{/* `grid-cols-[minmax(0,1fr)]` is load-bearing: an implicit `auto` track
takes its minimum from the widest thing inside it, so anything in the
form that cannot wrap sets the width of the whole section. */}
<Container className="relative grid grid-cols-[minmax(0,1fr)] items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
<div ref={copy.ref} className={cx('min-w-0', copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="text-[clamp(39px,6vw,68px)] compact:text-[32px] compact:tracking-[-0.035em]">
Запустим физиотерапию с нуля под ваш поток
</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,158 @@
import { useMemo, useState } from 'react'
import { calculatorDefaults, calculatorFields } 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']
/** Empty, negative and unparseable fields all read as "nothing entered yet". */
function toNumber(raw: string) {
const value = Number(raw.replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(value) && value > 0 ? value : 0
}
const EMPTY = '—'
/** The box every field and every result sits in; only the fill differs. */
const boxClass = 'min-w-0 rounded-[15px] border border-white/[0.13] p-[14px]'
export function RevenueCalculator() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
/**
* Strings, not numbers: a cleared field has to stay cleared while the reader
* types the next figure into it, and `Number('')` is 0 — indistinguishable
* from a deliberate zero.
*/
const [values, setValues] = useState<Record<FieldKey, string>>({
courses: String(calculatorDefaults.courses),
averageCheck: String(calculatorDefaults.averageCheck),
investment: String(calculatorDefaults.investment),
})
const results = useMemo(() => {
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
// Payback and ROI need both a monthly figure and an investment; with either
// missing the pair stays blank rather than reading as an instant return.
const payback = month && investment ? investment / month : 0
const roi = month && investment ? ((year - investment) / investment) * 100 : 0
return [
{
label: 'Дополнительная выручка / месяц',
value: month ? `${money.format(month)}` : EMPTY,
note: 'до вычета расходов',
accent: true,
},
{
label: 'Дополнительная выручка / год',
value: year ? `${money.format(year)}` : EMPTY,
note: 'при сохранении выбранной загрузки',
},
{
label: 'Срок возврата инвестиций',
value: payback ? `${decimal.format(payback)} мес.` : EMPTY,
note: 'инвестиции ÷ дополнительная выручка в месяц',
},
{
label: 'ROI за 12 месяцев',
value: roi ? `${money.format(Math.round(roi))}%` : EMPTY,
note: 'сценарный расчёт до операционных расходов',
},
]
}, [values])
return (
<div
ref={ref}
className={cx(
'calculator-surface mb-[32px] rounded-[22px] border border-[#39E0D2]/[0.34] p-[clamp(20px,2.5vw,28px)] shadow-[0_18px_46px_rgb(0_0_0/0.14)] compact:mb-[26px] compact:rounded-[18px] compact:p-[17px]',
revealClass,
)}
>
<div className="mb-[18px] grid items-end gap-[18px] lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)]">
<div>
<span className="mb-[6px] block text-[10px] font-extrabold tracking-[0.12em] text-[#39E0D2] uppercase">
Калькулятор сценария
</span>
<h3 className="mb-0 text-[clamp(20px,2.2vw,29px)] leading-[1.1] tracking-[-0.025em] text-white">
Посчитайте выручку и возврат инвестиций
</h3>
</div>
<p className="text-[13px] leading-[1.45] text-white/[0.61] lg:max-w-[420px] lg:text-right">
Три показателя и сразу видно месячную выручку, годовой результат, срок возврата и ROI.
</p>
</div>
<div className="mb-[12px] grid gap-[12px] sm:grid-cols-2 lg:grid-cols-3">
{calculatorFields.map((field, index) => (
<div
key={field.key}
className={cx(
boxClass,
'bg-navy-deep/[0.58]',
// Three fields across two columns leave the last one alone on its
// row; spanning it keeps the bottom edge straight.
index === calculatorFields.length - 1 && 'sm:col-span-full lg:col-span-1',
)}
>
<label htmlFor={field.id} className="mb-[7px] block text-[11px] leading-[1.3] font-[760] text-white/[0.78]">
{field.label}
</label>
<input
id={field.id}
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-[48px] w-full rounded-[11px] border border-[#39E0D2]/[0.42] bg-white px-[13px] text-[clamp(18px,2vw,24px)] font-extrabold text-[#071f35] tabular-nums outline-none transition-[border-color,box-shadow] duration-200 focus:border-[#39E0D2] focus:shadow-[0_0_0_3px_rgb(57_224_210/0.12)]"
/>
<small className="mt-[7px] block text-[10px] leading-[1.35] text-white/[0.45]">{field.hint}</small>
</div>
))}
</div>
<div aria-live="polite" className="grid gap-[12px] sm:grid-cols-2 lg:grid-cols-4">
{results.map((result) => (
<div
key={result.label}
className={cx(
boxClass,
'flex min-h-[108px] flex-col justify-between compact:min-h-[98px]',
result.accent ? 'calculator-result-accent border-[#39E0D2]/[0.48]' : 'calculator-result',
)}
>
<span className="text-[10px] leading-[1.3] font-[760] tracking-[0.035em] text-white/[0.63] uppercase">
{result.label}
</span>
<strong
className={cx(
'mt-[8px] mb-[4px] block text-[clamp(20px,2.2vw,29px)] leading-none tracking-[-0.03em] whitespace-nowrap tabular-nums compact:text-[clamp(23px,8vw,30px)]',
result.accent ? 'text-[#39E0D2]' : 'text-white',
)}
>
{result.value}
</strong>
<small className="text-[9.5px] leading-[1.3] text-white/[0.43]">{result.note}</small>
</div>
))}
</div>
<p className="mt-[13px] text-[11px] leading-[1.45] text-white/[0.52]">
<strong className="text-white/[0.78]">Как читать расчёт:</strong> выручка показывает потенциал продаж. Срок
возврата и ROI ориентиры до учёта ФОТ, аренды, расходников и налогов. На финальной модели эти расходы
добавляются отдельно.
</p>
</div>
)
}
@@ -0,0 +1,84 @@
import { revenueDrivers, 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'
/**
* `.fin-model` paints its own band and, unlike every other section, keeps the
* 32px gutter at all widths — `.fin-model .container` outranks the 760px rule
* that widens the shared container to 48px. Hence the inline wrapper here
* rather than <Section>/<Container>.
*/
export function ScenariosSection() {
const cta = useReveal<HTMLDivElement>()
return (
<section id="financial-scenarios" className="bg-scenario py-[clamp(68px,8vw,110px)] text-white">
<div className="mx-auto w-[min(calc(100%_-_32px),var(--container-page))]">
<p className="mb-[14px] flex items-center gap-[10px] text-[12px] font-extrabold tracking-[0.12em] text-[#39E0D2] uppercase before:h-[2px] before:w-[26px] before:rounded-[2px] before:bg-teal before:content-['']">
Сценарии выручки
</p>
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em] compact:text-[29px] compact:tracking-[-0.03em]">
Даже умеренная загрузка кабинета даёт понятную выручку
</h2>
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72] compact:mb-[26px]">
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
срок окупаемости.
</p>
<div className="mb-[28px] grid grid-cols-[auto_1fr_auto] items-center gap-[18px] rounded-md border border-teal/[0.28] bg-white/[0.035] px-[24px] py-[20px] narrow:grid-cols-1 narrow:gap-[8px]">
<span className="text-[12px] font-extrabold tracking-[0.06em] text-teal-deep uppercase">Формула расчёта</span>
<div className="text-[clamp(18px,2vw,24px)] font-extrabold">оплаченные курсы × средний чек 27 000 </div>
<span className="text-right text-[12px] text-white/[0.55] narrow:text-left">без учёта расходов</span>
</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-[24px] border border-teal/[0.26] bg-[#0e3b5b] px-[24px] py-[26px] text-white"
>
<div>
<strong className="block text-[clamp(34px,4vw,48px)] leading-none text-[#39E0D2]">
{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('mt-[26px] flex justify-center compact:mt-[22px]', cta.revealClass)}>
<ButtonLink href="#proposal" className="min-w-[270px] compact:w-full compact:min-w-0">
<span>Как получить выручку?</span>
<ArrowRightIcon />
</ButtonLink>
</div>
</div>
</section>
)
}
@@ -0,0 +1,29 @@
import logo from '../assets/images/exo-logo.png'
import { footerLegal } from '../data/content'
import { Container } from './Layout'
export function SiteFooter() {
return (
/* The extra bottom padding below 980px is the landing strip for <ThumbBar />. */
<footer className="bg-footer py-[26px] pb-[96px] text-[12px] text-white/[0.52] lg: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]">{footerLegal}</div>
<div className="flex flex-wrap justify-between gap-[10px] border-t border-white/[0.09] pt-[13px] md:col-span-full">
<span>© 2026 Экзо Групп</span>
<span>Российские технологии реабилитации</span>
</div>
</Container>
</footer>
)
}
@@ -0,0 +1,67 @@
import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content'
import { useScrolled } from '../hooks/useScrollState'
import { cx } from '../lib/cx'
import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons'
import { Container } from './Layout'
/**
* Transparent over the hero; gains the blurred bar once the page scrolls.
*
* Below 980px it carries a logo and a call button and nothing else. The nav is
* not shown at that width and the proposal button has moved to <ThumbBar />,
* so there is nothing left up here to compete with the page.
*/
export function SiteHeader() {
const scrolled = useScrolled()
return (
<header
className={cx(
'fixed inset-x-0 top-0 z-[60] py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250 phone:py-[9px]',
scrolled && 'bg-navy-deep/[0.88] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
)}
>
<Container className="flex items-center justify-between gap-[18px]">
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — наверх">
<img
alt="Экзо Групп — российские технологии реабилитации"
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left phone:w-[168px]"
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-[10px] phone:gap-[6px]">
<a
href={contacts.phoneHref}
aria-label="Позвонить в Экзо Групп"
className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
>
<PhoneIcon />
<span className="phone:hidden">Звонок</span>
</a>
{/* `max-lg:hidden`, not `hidden lg:inline-flex`: `inline-flex` from
the button base is emitted after `hidden` in the utility layer and
would win at every width. */}
<ButtonLink href="#proposal" className="max-lg:hidden" size={headerSize}>
Получить предложение
</ButtonLink>
</div>
</Container>
</header>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from 'react'
import { contacts, sectionNav } from '../data/content'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { cx } from '../lib/cx'
import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons'
const hrefs = sectionNav.map((section) => section.href)
/**
* Wayfinding and the one call to action, moved to the thumb — below 980px,
* which is exactly where the header nav stops being rendered.
*
* The page runs to seventeen screens on a phone. What a reader needs there is
* "where am I" and "how do I ask", and until now the top of the screen carried
* neither: it carried a logo and a button, pinned over the content the whole
* way down. The header gives that width back to the page; this takes over.
*/
export function ThumbBar() {
const { index, progress } = useReadingPosition(hrefs)
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const current = sectionNav[index]
/* Hidden over the hero, which has its own call to action, and again over the
form, where the bar would sit on top of the submit button. */
const visible = index > 0 && current.href !== '#proposal'
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
const { overflow } = document.body.style
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = overflow
window.removeEventListener('keydown', onKeyDown)
}
}, [open])
const close = () => {
setOpen(false)
triggerRef.current?.focus()
}
return (
<>
<div
className={cx(
'fixed inset-x-0 bottom-0 z-70 transition-[transform,opacity] duration-300 lg:hidden',
visible ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-full opacity-0',
)}
>
<div className="border-t border-white/[0.1] bg-navy-deep/[0.93] backdrop-blur-[20px]">
<div aria-hidden="true" className="h-[2px] bg-white/[0.08]">
<i className="block h-full bg-teal transition-[width] duration-150" style={{ width: `${progress * 100}%` }} />
</div>
<div className="mx-auto flex w-[min(calc(100%_-_24px),var(--container-page))] items-center gap-[10px] py-[10px]">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-[10px] rounded-[16px] border border-white/[0.14] bg-white/[0.06]
px-[13px] py-[8px] text-left transition-[background-color,border-color] duration-200 outline-none
hover:bg-white/[0.1] focus-visible:border-teal focus-visible:ring-2 focus-visible:ring-teal/[0.4]"
>
<span className="min-w-0">
<span className="block text-[10px] leading-[1.4] font-[850] tracking-[0.13em] text-teal uppercase">
Раздел {index + 1} / {sectionNav.length}
</span>
<span className="block truncate text-[14px] font-[760] text-white">{current.label}</span>
</span>
<ChevronUpIcon className="ml-auto size-[18px] shrink-0 text-white/[0.5]" />
</button>
<a
href="#proposal"
className="inline-flex min-h-[46px] shrink-0 cursor-pointer items-center rounded-full bg-teal px-[18px] text-[14px]
font-[790] text-navy shadow-[0_12px_32px_rgb(0_196_180/0.28)] transition-colors duration-200 hover:bg-teal-bright"
>
Получить аудит
</a>
</div>
</div>
</div>
{open ? (
<div className="fixed inset-0 z-80 lg:hidden" role="dialog" aria-modal="true" aria-label="Разделы страницы">
<button
type="button"
aria-label="Закрыть список разделов"
onClick={close}
className="veil-in absolute inset-0 size-full cursor-pointer bg-navy-deep/[0.66] backdrop-blur-[3px]"
/>
<div
className="sheet-in absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-[26px] border-t border-white/[0.12]
bg-navy-deep px-[16px] pt-[12px] pb-[20px] shadow-[0_-24px_60px_rgb(3_20_34/0.5)]"
>
<div className="mb-[14px] flex items-center justify-between">
<span className="text-[11px] font-[850] tracking-[0.14em] text-teal uppercase">Разделы страницы</span>
<button
type="button"
onClick={close}
aria-label="Закрыть"
className="grid size-[34px] cursor-pointer place-items-center rounded-full border border-white/[0.14] text-white/[0.6]
transition-colors duration-200 hover:text-white"
>
<CloseIcon className="size-[16px]" />
</button>
</div>
<nav className="grid gap-[2px]">
{sectionNav.map((section, position) => (
<a
key={section.href}
href={section.href}
onClick={close}
aria-current={position === index ? 'true' : undefined}
className={cx(
'flex items-center gap-[14px] rounded-[14px] px-[13px] py-[12px] text-[15px] transition-colors duration-200',
position === index ? 'bg-white/[0.09] font-[780] text-white' : 'text-white/[0.66] hover:text-white',
)}
>
<span
className={cx(
'w-[18px] shrink-0 text-[11px] font-[820] tabular-nums',
position === index ? 'text-teal' : 'text-white/[0.34]',
)}
>
{String(position + 1).padStart(2, '0')}
</span>
{section.label}
</a>
))}
</nav>
<a
href={contacts.phoneHref}
className="mt-[14px] flex min-h-[52px] items-center justify-center gap-[10px] rounded-[16px] border border-white/[0.16]
bg-white/[0.07] text-[15px] font-[760] text-white transition-colors duration-200 hover:bg-white/[0.12]
[&>svg]:size-[18px]"
>
<PhoneIcon />
{contacts.phone}
</a>
</div>
</div>
) : null}
</>
)
}
@@ -0,0 +1,82 @@
import productionSite from '../assets/images/production-site.webp'
import { companyStats, companyStatsNote, photoCaption, 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'
import { Rail } from './Rail'
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="lazy"
src={productionSite}
/>
<div className="absolute inset-x-[23px] bottom-[21px] z-1 text-white">
<strong className="block text-[22px]">{photoCaption.title}</strong>
<span className="block text-[13px] text-white/[0.67]">{photoCaption.text}</span>
</div>
</div>
<div className="min-w-0">
<Rail
outerRef={list.ref}
wrapperClassName={list.revealClass}
label="Почему Экзо Групп"
className="scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still"
>
{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>
))}
</Rail>
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] md: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]">{companyStatsNote}</SmallNote>
</div>
</div>
</Container>
</Section>
)
}
+504
View File
@@ -0,0 +1,504 @@
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: 'Запуск' },
]
/**
* Every section, in reading order — the list behind the thumb bar below 980px,
* where the header nav above is not shown. It is longer than `navLinks` on
* purpose: the four header links are a summary for a reader who can see the
* whole page at once, this is a map for one scrolling through seventeen
* screens of it.
*/
export const sectionNav = [
{ href: '#top', label: 'Начало' },
{ href: '#market', label: 'Рынок' },
{ href: '#financial-scenarios', label: 'Сценарии выручки' },
{ href: '#project', label: 'Что запускаем' },
{ href: '#programs', label: 'Курсовая модель' },
{ href: '#technology', label: 'Оборудование' },
{ href: '#launch', label: 'Запуск' },
{ href: '#why-exo', label: 'Почему Экзо' },
{ href: '#proposal', label: 'Заявка на аудит' },
]
/* -------------------------------------------------------------------------- */
/* Первый экран */
/* -------------------------------------------------------------------------- */
export const heroFacts = [
{ value: 'от 12 м²', label: 'стартовый формат одного кабинета' },
{ value: '90 дней', label: 'до контрольной точки пилота' },
{
value: '35% к чистой прибыли',
label: 'превращаем кабинет физиотерапии в главный источник дохода клиники',
},
]
/* -------------------------------------------------------------------------- */
/* Рыночный контекст */
/* -------------------------------------------------------------------------- */
export const marketStats = [
{
value: '2,41',
label: 'млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация',
source: 'WHO / IHME',
},
{ value: '+63%', label: 'рост глобальной потребности в реабилитации с 1990 года', source: 'WHO / IHME' },
{
value: '1,0 → 1,4',
label: 'млн пациентов медицинской реабилитации в России, 20202024',
source: 'BusinesStat',
},
{
value: '5,1%',
label: 'прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 20262033',
source: 'Grand View Research',
},
]
/** «Пока физиотерапии нет» — левая колонка карточки маршрута. */
export const routeWithout = [
'пациент ищет процедуры в другом месте;',
'у врача нет аппаратного этапа комплексной программы;',
'нет отдельной линейки курсов и пакетов;',
'часть повторных визитов и выручки остаётся вне центра.',
]
/** «После запуска направления» — правая, акцентная колонка. */
export const routeWith = [
'пациент проходит маршрут внутри одной клиники;',
'назначение, процедуры и контроль связаны в курс;',
'существующая база формирует стартовую загрузку;',
'руководитель видит конверсию, завершение и экономику.',
]
export const marketNotes = [
'Рыночные показатели приведены по WHO/IHME, BusinesStat и Grand View Research. Они описывают рынок и потребность, но не являются прогнозом загрузки конкретного медицинского центра.',
'CAGR (Compound Annual Growth Rate) — среднегодовой темп роста: показатель, который показывает, каким был бы постоянный средний процент роста рынка за каждый год выбранного периода.',
]
/* -------------------------------------------------------------------------- */
/* Сценарии выручки */
/* -------------------------------------------------------------------------- */
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 calculator opens on the middle scenario above — 60 courses at 27 000 ₽ —
* so its first reading reproduces the «1,62 млн ₽» already on the card next
* to it, and the reader changes numbers from a figure they have just seen.
*/
export const calculatorDefaults = {
courses: 60,
averageCheck: 27000,
investment: 5000000,
}
export const calculatorFields = [
{
key: 'courses' as const,
id: 'courseCalcCount',
label: 'Оплаченных курсов в месяц',
hint: 'Сколько курсов кабинет реально продаёт за месяц.',
ariaLabel: 'Количество оплаченных курсов в месяц',
step: 1,
},
{
key: 'averageCheck' as const,
id: 'courseCalcCheck',
label: 'Средний чек курса, ₽',
hint: 'Средняя стоимость одного оплаченного курса.',
ariaLabel: 'Средний чек курса в рублях',
step: 500,
},
{
key: 'investment' as const,
id: 'courseCalcInvestment',
label: 'Инвестиции в запуск, ₽',
hint: 'Стартовые вложения для расчёта срока возврата и ROI.',
ariaLabel: 'Инвестиции в запуск кабинета в рублях',
step: 100000,
},
]
/* -------------------------------------------------------------------------- */
/* Готовое направление */
/* -------------------------------------------------------------------------- */
export const promises = [
{
icon: 'retain',
title: 'Удержать пациента',
text: 'Добавить недостающий аппаратный этап в маршрут лечения и реабилитации внутри центра.',
},
{
icon: 'revenue',
title: 'Создать новую выручку',
text: 'Запустить отдельную платную линейку процедур, курсов и комплексных программ.',
},
{
icon: 'clinical',
title: 'Усилить врачебный продукт',
text: 'Связать назначение врача, технологии, длительность курса и контроль динамики.',
},
{
icon: 'metrics',
title: 'Управлять показателями',
text: 'Измерять назначения, загрузку, завершение курса, повторные визиты и экономику.',
},
] as const
export const systemStatement =
'Сначала выбирается клиническая задача. Затем — технология и формат кабинета.'
export const profileChipsNote = 'Профили, для которых особенно актуален предварительный аудит:'
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 equipmentNote =
'Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.'
/* -------------------------------------------------------------------------- */
/* Курсовая модель */
/* -------------------------------------------------------------------------- */
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: 'Выполнение по назначению, карте и утверждённому регламенту.' },
{
title: 'Контроль динамики',
text: 'Выбранные клинические показатели, переносимость и завершение курса.',
},
{ title: 'Повторный приём', text: 'Оценка результата, корректировка и следующий этап по показаниям.' },
]
/* -------------------------------------------------------------------------- */
/* Пространство и запуск */
/* -------------------------------------------------------------------------- */
export const formats = [
{
area: 'от 12 м²',
title: 'Один кабинет',
text: 'Стартовый формат под отдельный сценарий и ограниченную конфигурацию.',
},
{
area: 'от 35 м²',
title: 'Два кабинета',
text: 'Базовая инфраструктура с разделением активных и параллельных процедур.',
},
{
area: 'от 45 м²',
title: 'Три кабинета',
text: 'Расширенный формат для нескольких маршрутов и большей пропускной способности.',
},
]
export const formatNote =
'Финальный метраж, зонирование и состав фиксируются только после аудита помещения и медицинской модели.'
export const timeline = [
{
week: '12 неделя.',
title: 'Сбор данных и предпроектный анализ потенциала',
lead: 'Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы.',
items: [
{
title: 'Анализ действующей базы и потока',
text: 'Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.',
},
{
title: 'Аудит помещений и эргономики',
text: 'Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.',
},
{
title: 'Экспертиза лицензирования',
text: 'Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.',
},
{
title: 'Оценка команды и выбор маршрутов',
text: 'Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.',
},
],
},
{
week: '35 неделя.',
title: 'Разработка бизнес-проекта и EXO-интеграции',
lead: 'Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ.',
items: [
{
title: 'Создание концепции направления',
text: 'Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.',
},
{
title: 'Подбор оптимального состава',
text: 'Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.',
},
{
title: 'Проектирование планировки и сан-логистики',
text: 'Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.',
},
{
title: 'Юридическое сопровождение',
text: 'Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.',
},
{
title: 'Расчет экономики и плана пилота',
text: 'Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.',
},
],
},
{
week: '68 неделя.',
title: 'Бесшовная подготовка, оснащение и обучение команды',
lead: 'Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса.',
items: [
{
title: 'Поставка и шеф-монтаж',
text: 'Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.',
},
{
title: 'Обучение врачей с нуля',
text: 'Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.',
},
{
title: 'Практика медсестер и администраторов',
text: 'Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.',
},
{
title: 'Внедрение процедурных карт',
text: 'Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.',
},
{
title: 'Настройка записи и расписания',
text: 'Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.',
},
],
},
{
week: '912 неделя.',
title: 'Запуск пилотного проекта и выход на плановую прибыль',
lead: 'Тестовый запуск направления на вашей базе, контроль стандартов сервиса и получение первой коммерческой выручки.',
items: [
{
title: 'Запуск назначений и продаж',
text: 'Перевод первых целевых пациентов из вашей действующей базы на новые EXO-курсы вместо разовых визитов.',
},
{
title: 'Контроль медицинских и бизнес-показателей',
text: 'Сквозной мониторинг конверсии из приема в покупку курса, отслеживание динамики лечения пациентов по шкалам «до / после».',
},
{
title: 'Разбор отклонений и адаптация',
text: 'Оперативная корректировка работы персонала, разбор сложных клинических случаев и донастройка скриптов администраторов нашими экспертами.',
},
{
title: 'Внедрение премиального сервиса',
text: 'Контроль удовлетворенности пациентов для формирования лояльной базы и запуска сарафанного радио.',
},
],
},
]
/* -------------------------------------------------------------------------- */
/* Почему Экзо Групп */
/* -------------------------------------------------------------------------- */
export const whyItems = [
{
icon: 'production',
title: 'Собственная разработка и сервис',
text: 'Линейка высокоинтенсивных и восстановительных технологий внутри одного продуктового контура.',
},
{
icon: 'clinics',
title: 'Сеть Экзо Клиник',
text: 'Оборудование, маршруты и организация работы проверяются на реальных медицинских процессах.',
},
{
icon: 'shield',
title: 'Полный контур внедрения',
text: 'Проект, оборудование, клиническая логика, обучение, сервис, продажи, KPI и масштабирование.',
},
] as const
export const photoCaption = {
title: 'Российское производство',
text: 'Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».',
}
export const companyStats = [
{ value: '4', label: 'собственные клиники' },
{ value: '70+', label: 'партнёрских клиник' },
{ value: '60+', label: 'медицинских учреждений' },
{ value: '2 млн+', label: 'проведённых процедур' },
]
export const companyStatsNote =
'Количественные показатели — по корпоративным материалам Экзо Групп; перед массовой внешней публикацией рекомендуется финальная сверка.'
/* -------------------------------------------------------------------------- */
/* Заявка */
/* -------------------------------------------------------------------------- */
export const ctaChecks = [
'предварительный аудит базы и врачебных направлений;',
'варианты пространства и состава технологий;',
'план запуска на 90 дней и набор контрольных KPI.',
]
/* -------------------------------------------------------------------------- */
/* Подвал и резидентские статусы */
/* -------------------------------------------------------------------------- */
export const footerLegal =
'Материалы страницы предназначены для B2B-обсуждения проекта. Конкретный состав, медицинские показания, противопоказания, лицензирование, квалификация персонала, сроки и финансовая модель определяются после аудита центра и сверки с актуальной документацией медицинских изделий.'
export const residencies = [
{
href: 'https://isamara.ru/zhiguli-valley/',
label: 'Технопарк Жигулёвская долина',
width: 340,
height: 93,
},
{ href: 'https://sk.ru/', label: 'Участник проекта Сколково', width: 330, height: 88 },
]
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
/**
* For the few places where a phone needs different markup, not just different
* classes — the launch timeline, which becomes a set of disclosures rather
* than four full-height cards. `query` must be a literal, not a computed
* string.
*/
export function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false)
useEffect(() => {
const list = window.matchMedia(query)
const onChange = () => setMatches(list.matches)
onChange()
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
}, [query])
return matches
}
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
/**
* Which section the reader is currently in, plus how far through the page they
* are. Driven by scroll position rather than by an IntersectionObserver: every
* section here is taller than a phone viewport, so the question is not "is this
* visible" but "which one is under the reading line", and a threshold-based
* observer cannot answer that.
*
* `hrefs` must be a stable array — pass a module-level constant.
*/
export function useReadingPosition(hrefs: readonly string[]) {
const [index, setIndex] = useState(0)
const [progress, setProgress] = useState(0)
useEffect(() => {
let frame = 0
const measure = () => {
frame = 0
/* A third of the way down the screen: the line the eye actually reads
from, not the very top edge. */
const line = window.scrollY + window.innerHeight * 0.3
let current = 0
hrefs.forEach((href, position) => {
const element = document.querySelector(href)
if (element && element.getBoundingClientRect().top + window.scrollY <= line) current = position
})
const scrollable = document.documentElement.scrollHeight - window.innerHeight
setIndex(current)
setProgress(scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0)
}
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(measure)
}
measure()
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
if (frame) cancelAnimationFrame(frame)
}
}, [hrefs])
return { index, progress }
}
+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' }
}
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
/**
* The header gains its blurred background as soon as the page leaves the very
* top — 18px, same threshold as the original landing.
*/
export function useScrolled(threshold = 18) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [threshold])
return scrolled
}
+424
View File
@@ -0,0 +1,424 @@
@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-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;
}
.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. The slight upscale hides the edges the transform would
otherwise leave visible; the focal point moves right once the copy column
has room, so the equipment stays clear of the text. */
.hero-photo {
background-position: 65% center;
background-size: cover;
transform: scale(1.015);
}
@media (min-width: 980px) {
.hero-photo {
background-position: 72% center;
}
}
/* Soft teal blooms. The three on this page differ in both opacity and stop
position, so they cannot collapse into one class. */
.hero-bloom {
background: radial-gradient(circle, rgb(0 196 180 / 0.24), transparent 64%);
}
.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);
}
/* Revenue calculator, inside the dark "сценарии выручки" band. */
.calculator-surface {
background: linear-gradient(135deg, rgb(0 196 180 / 0.08), rgb(255 255 255 / 0.03));
}
.calculator-result {
background: linear-gradient(145deg, rgb(14 59 91 / 0.96), rgb(7 31 53 / 0.96));
}
.calculator-result-accent {
background: linear-gradient(145deg, rgb(0 196 180 / 0.18), rgb(7 31 53 / 0.98));
}
.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%);
}
.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);
}
.residency-surface {
background: linear-gradient(180deg, #071d31 0%, #041524 100%);
}
}
/* ---------------------------------------------------------------------------
Phone.
The scale above is a display scale. At 375px every level of it collapses
onto the same note — a 42px h1 and a 34px h2 set at display tracking, one
after another for seventeen screens, all shouting equally. Below 560px the
whole page steps down half a level and the tracking opens back up (-0.055em
closes Cyrillic counters at 38px in a way it never does at 84px), so the
figures the argument actually rests on — 2,41, +63%, 2,70 млн ₽ — are left
as the loudest thing on the page.
--------------------------------------------------------------------------- */
@media (max-width: 560px) {
:root {
--spacing-section: 54px;
--spacing-section-compact: 46px;
}
/* The phone header is 62px tall, not 78px. */
section[id] {
scroll-margin-top: 66px;
}
h1 {
margin-bottom: 18px;
font-size: 38px;
line-height: 1;
letter-spacing: -0.035em;
/* `balance` is for two or three lines. Over five it starts trimming lines
to lengths it likes and leaves a ragged right edge instead of a filled
column. */
text-wrap: normal;
}
h2 {
margin-bottom: 14px;
font-size: 29px;
line-height: 1.06;
letter-spacing: -0.03em;
}
h3 {
font-size: 19px;
letter-spacing: -0.02em;
}
}
@layer components {
/* The hero scrim runs left-to-right, which on a 375px screen means it runs
across the whole photo: the image is paid for and then buried. On phones
it turns vertical instead, so the equipment reads across the top third and
the copy sits on solid ground below it. */
@media (max-width: 560px) {
.hero-scrim {
background: linear-gradient(
180deg,
/* The header is transparent over the hero and its logo is white; the
wave wall behind it is cream. This first stop is what keeps it
readable. */ rgb(4 24 41 / 0.82) 0%,
rgb(4 24 41 / 0.3) 12%,
rgb(4 24 41 / 0.34) 22%,
rgb(4 24 41 / 0.62) 34%,
rgb(5 28 47 / 0.93) 54%,
rgb(4 22 37 / 0.99) 100%
);
}
/* At 375px `cover` fits the photo by height, so the only part left above
the copy is the strip the room happens to put at the top — ceiling
tiles. Zooming past cover and dropping the frame lands the backlit wave
wall in that strip instead, which is the one thing in this photo worth
showing at 110px tall. */
.hero-photo {
background-size: auto 150%;
background-position: 55% 72%;
}
/* Room for the rail indicator that replaced the "swipe sideways" hints. */
.scroll-cards {
padding-bottom: 12px;
}
}
@keyframes sheet-in {
from {
transform: translateY(16px);
opacity: 0;
}
}
@keyframes veil-in {
from {
opacity: 0;
}
}
.sheet-in {
animation: sheet-in 0.26s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.veil-in {
animation: veil-in 0.2s ease;
}
}
/* 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;
}
}
+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-no-physio'
const DRAFT_KEY = 'exo_medcenter_no_physio_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_no_physio_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"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
// Set VITE_BASE_PATH=/medical-centers/no-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: {
// 5173/3000 and 5174/3001 belong to the fitnes/medcenter and hotel
// landings, so all of them can run side by side.
port: 5175,
proxy: {
'/api': { target: 'http://localhost:3003', changeOrigin: true },
},
},
})