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>
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "medcenter-no-physio-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:client"],
|
||||
"port": 5175
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -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 это правило было
|
||||
перебито, и подложка там видна всегда).
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
MedCbezFiz — медицинский центр без физиотерапевтического кабинета
|
||||
|
||||
Готовый автономный лендинг.
|
||||
Файл index.html содержит встроенные стили, JavaScript и изображения.
|
||||
Для размещения загрузите index.html в нужную директорию сервера.
|
||||
@@ -0,0 +1,14 @@
|
||||
ГОТОВО К ЗАГРУЗКЕ НА СЕРВЕР
|
||||
|
||||
Загрузить вместе:
|
||||
1. index.html
|
||||
2. styles.css
|
||||
3. script.js
|
||||
4. папку assets целиком
|
||||
|
||||
После загрузки проверьте:
|
||||
- открывается первый экран и фон;
|
||||
- отображаются логотип и карточки оборудования;
|
||||
- работает калькулятор;
|
||||
- кнопки прокручивают к нужным блокам;
|
||||
- форма отправляется в /api/leads/medical-centers-no-physio.
|
||||
@@ -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`.
|
||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
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 |
|
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 |
|
After Width: | Height: | Size: 34 KiB |
@@ -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();
|
||||
})();
|
||||
@@ -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)}}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 34 KiB |
@@ -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.')
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)',
|
||||
)
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> }
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
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 |
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 3–5 from someone still reading week 1–2, 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: 'млн пациентов медицинской реабилитации в России, 2020–2024',
|
||||
source: 'BusinesStat',
|
||||
},
|
||||
{
|
||||
value: '5,1%',
|
||||
label: 'прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 2026–2033',
|
||||
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: '1–2 неделя.',
|
||||
title: 'Сбор данных и предпроектный анализ потенциала',
|
||||
lead: 'Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы.',
|
||||
items: [
|
||||
{
|
||||
title: 'Анализ действующей базы и потока',
|
||||
text: 'Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.',
|
||||
},
|
||||
{
|
||||
title: 'Аудит помещений и эргономики',
|
||||
text: 'Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.',
|
||||
},
|
||||
{
|
||||
title: 'Экспертиза лицензирования',
|
||||
text: 'Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.',
|
||||
},
|
||||
{
|
||||
title: 'Оценка команды и выбор маршрутов',
|
||||
text: 'Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '3–5 неделя.',
|
||||
title: 'Разработка бизнес-проекта и EXO-интеграции',
|
||||
lead: 'Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ.',
|
||||
items: [
|
||||
{
|
||||
title: 'Создание концепции направления',
|
||||
text: 'Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.',
|
||||
},
|
||||
{
|
||||
title: 'Подбор оптимального состава',
|
||||
text: 'Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.',
|
||||
},
|
||||
{
|
||||
title: 'Проектирование планировки и сан-логистики',
|
||||
text: 'Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.',
|
||||
},
|
||||
{
|
||||
title: 'Юридическое сопровождение',
|
||||
text: 'Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.',
|
||||
},
|
||||
{
|
||||
title: 'Расчет экономики и плана пилота',
|
||||
text: 'Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '6–8 неделя.',
|
||||
title: 'Бесшовная подготовка, оснащение и обучение команды',
|
||||
lead: 'Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса.',
|
||||
items: [
|
||||
{
|
||||
title: 'Поставка и шеф-монтаж',
|
||||
text: 'Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.',
|
||||
},
|
||||
{
|
||||
title: 'Обучение врачей с нуля',
|
||||
text: 'Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.',
|
||||
},
|
||||
{
|
||||
title: 'Практика медсестер и администраторов',
|
||||
text: 'Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.',
|
||||
},
|
||||
{
|
||||
title: 'Внедрение процедурных карт',
|
||||
text: 'Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.',
|
||||
},
|
||||
{
|
||||
title: 'Настройка записи и расписания',
|
||||
text: 'Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '9–12 неделя.',
|
||||
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 },
|
||||
]
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cx(...values: (string | false | null | undefined)[]): string {
|
||||
return values.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -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>[]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.server.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||