Convert "physiotherapy from scratch" landing to React + Tailwind v4

Ports the last single-file page, MedCbezFiz — the medical centre that has no
physiotherapy yet — to the architecture the other three landings now share:
Vite + React 19 + TypeScript + Tailwind v4, Express 5 for the API, zod schema
shared between client and server.

The original is kept in legacy/index.html as the visual reference, with its
instructions alongside it in legacy/README.md. Its 4 MB collapse to 317 KB of
JS plus assets loaded on demand. Fourteen inlined images were extracted; twelve
turned out byte-identical to the medcenter landing's, and the two large device
PNGs (1.5 MB and 750 KB) had identical sources, so the webp conversions from
that landing are reused rather than redone — 664 KB of assets instead of 2.8 MB.

Text was verified rather than eyeballed: the app is server-rendered to static
HTML and its visible text diffed against the legacy file. 280 blocks against
281, and the only difference is the honeypot's screen-reader label, which the
original has no equivalent for.

Two things in the original are dead and were deliberately not ported: 39
.patient-economics / .base-economics-card rules that no element uses, and the
[data-counter] animation handler, which likewise matches nothing on the page.
Both are documented in the README.

Departures from the medcenter landing this was built from, all following the
original: the hero photo is anchored at 65% (72% from 980px), the header is
transparent until 18px of scroll instead of always painted, the revenue band
keeps 32px gutters at every width, and the launch timeline breaks into two
columns at 900px rather than 760px.

Leads post to /api/leads/medical-centers-no-physio, the address the original
page already referenced, into pipeline 10980758. The cabinet_state select has
no counterpart on this form and was dropped from the schema, mapper and check
script. Vite runs on 5175 and the API on 3002 so all four landings can run at
once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-08-28 19:59:29 +06:00
co-authored by Claude Fable 5
parent 8b954ea5db
commit cd3414d8d0
62 changed files with 8417 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "medcenter-no-physio-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:client"],
"port": 5175
}
]
}
+35
View File
@@ -0,0 +1,35 @@
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 3000 and 3001 belong to the fitnes/medcenter and hotel landings.
PORT=3002
# ---------------------------------------------------------------------------
# amoCRM — server-side only, never exposed to the browser
# ---------------------------------------------------------------------------
# Your account subdomain: for https://exotherapy.amocrm.ru put "exotherapy"
AMO_SUBDOMAIN=
# Long-lived access token.
# amoCRM → Настройки → Интеграции → Создать интеграцию → Внешняя интеграция →
# open it → tab "Ключи и скопы" → «Генерировать токен» (долгосрочный, ~1 год).
AMO_LONG_LIVED_TOKEN=
# Pipeline (воронка) the leads land in. The lead is placed in its first stage.
AMO_PIPELINE_ID=10980758
# Optional: numeric id of the amoCRM user leads are assigned to.
AMO_RESPONSIBLE_USER_ID=
# Optional: comma-separated tags added to every lead.
AMO_LEAD_TAGS=medcenter-no-physio-landing
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.local
.DS_Store
+198
View File
@@ -0,0 +1,198 @@
# Физиотерапия с нуля — лендинг для медицинских центров
React-приложение лендинга MedCbezFiz (медицинский центр, где физиотерапии ещё
нет). Каждая заявка создаёт сделку в amoCRM (воронка `10980758`) и контакт, если
его ещё нет.
Стек: **Vite + React 19 + TypeScript + Tailwind CSS v4**, API — **Express 5**.
---
## Быстрый старт
```bash
npm install
cp .env.example .env # заполнить AMO_SUBDOMAIN и AMO_LONG_LIVED_TOKEN
npm run dev
```
`npm run dev` поднимает Vite на `http://localhost:5175` и API на `:3002`;
запросы `/api/*` проксируются с фронтенда на API.
Порты выбраны так, чтобы все лендинги можно было запускать одновременно:
`fitnes` и `medcenter` — 5173/3000, `hotel` — 5174/3001, этот — 5175/3002.
## Продакшен
```bash
npm run build # сборка клиента в dist/client
npm start # Express отдаёт dist/client и обрабатывает /api/*
```
Один процесс Node на любом VPS. Порт — `PORT` (по умолчанию 3002).
---
## amoCRM
### 1. Получить долгосрочный токен
1. amoCRM → **Настройки → Интеграции → Создать интеграцию → Внешняя интеграция**.
2. Название любое (например «Сайт medcenter без физио»), redirect URI — любой
рабочий адрес сайта: для долгосрочного токена он не используется.
3. Права доступа: достаточно **Сделки, Контакты** (чтение и запись).
4. Сохранить, открыть интеграцию → вкладка **«Ключи и скопы»** →
**«Генерировать токен»** (долгосрочный, действует ~1 год).
5. Скопировать токен в `.env` в `AMO_LONG_LIVED_TOKEN`, поддомен — в
`AMO_SUBDOMAIN` (для `https://exotherapy.amocrm.ru` это `exotherapy`).
Токен читается только на сервере и никогда не попадает в браузерный бандл.
Пометьте в календаре дату истечения — токен нужно перевыпустить через год.
### 2. Проверить подключение
```bash
npm run amo:check
```
Скрипт выведет название аккаунта, первый этап воронки `10980758`, в который
попадут сделки, и то, в какие поля вашего аккаунта легли данные формы.
### 3. Что происходит при отправке формы
1. Поиск контакта по телефону (во всех написаниях: `+7…`, `8…`, только цифры)
и по email. Если контакт найден — используется он, недостающий телефон или
email дописывается в карточку.
2. Если контакта нет — создаётся новый с именем, телефоном и email.
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`.
4. Данные, для которых в аккаунте есть подходящее поле (компания, направления
центра, `utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к
сделке. Ничего настраивать в amoCRM заранее не нужно.
Эндпоинт — `POST /api/leads/medical-centers-no-physio` (адрес взят из исходного
лендинга).
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи.
### Переменные окружения
| Переменная | Обязательна | Описание |
|---|---|---|
| `AMO_SUBDOMAIN` | да | Поддомен аккаунта, например `exotherapy` |
| `AMO_LONG_LIVED_TOKEN` | да | Долгосрочный токен интеграции |
| `AMO_PIPELINE_ID` | нет | Воронка для сделок (по умолчанию `10980758`) |
| `AMO_RESPONSIBLE_USER_ID` | нет | Ответственный за сделку пользователь |
| `AMO_LEAD_TAGS` | нет | Теги через запятую для всех сделок |
| `PORT` | нет | Порт API/продакшен-сервера (`3002`) |
| `VITE_SITE_URL` | нет | Канонический адрес, подставляется в `canonical` и `og:` |
| `VITE_BASE_PATH` | нет | Подпуть размещения, например `/medical-centers/no-physiotherapy/` (по умолчанию `/`) |
---
## Структура
```
index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
hooks/ появление секций при скролле, состояние шапки
lib/ отправка заявки, маска телефона
assets/images/ 12 изображений, извлечённых из исходного HTML
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM
legacy/index.html исходный однофайловый лендинг (визуальный эталон)
legacy/README.md исходная инструкция к однофайловой версии
```
### Дизайн-система
Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: `sm` 600, `md` 760, `lg` 980, плюс
`max-width`-варианты `stack` (1050), `narrow` (760), `compact` (560),
`phone` (520), `tiny` (430). Они объявлены от широкого к узкому — Tailwind
выводит варианты в порядке объявления, и более узкий должен идти позже, чтобы
выигрывать.
Горизонтальные ряды карточек (рынок, обещания, программы, оборудование,
форматы, план запуска) используют класс `.scroll-cards`: на телефоне это лента
со snap-скроллом, а на нужной ширине секция сама возвращает её в обычную сетку
через `sm:grid-still` / `md:grid-still`.
Два места намеренно выбиваются из общей сетки, повторяя оригинал:
* блок «Сценарии выручки» (`.fin-model`) сохраняет отступ 32 px на всех
ширинах — в исходном CSS правило `.fin-model .container` перебивает общее
расширение контейнера до 48 px с 760 px;
* план запуска (`.timeline`) переходит в две колонки на 900 px, а не на 760 px,
как остальные секции.
### Шрифты
Inter подключён локально пакетом `@fontsource-variable/inter` — внешних запросов
(Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном
100–900, поэтому нестандартные веса из макета (720, 760, 790, 850) отрисовываются
по-настоящему, а не округляются до `bold`.
Символы `≈ ×` не входят в подсеты Inter у Fontsource, поэтому для них всегда
используется системный фолбэк. Это заметно только при очень крупном кегле.
`Manrope` и `Segoe UI` остались в стеке `--font-sans` как фолбэк, но отдельно не
загружаются.
### Экономика направления
Цифры блока «Сценарии выручки» — статические данные из `src/data/content.ts`,
как в исходном лендинге. Калькулятора на странице нет: расчёт делается на
аудите.
---
## Соответствие оригиналу
Текст страницы сверялся с `legacy/index.html` построчно: приложение
рендерилось в статический HTML, из обоих файлов извлекался видимый текст и
сравнивался. Совпадение полное — единственное расхождение — скрытая подпись
honeypot-поля, которой в оригинале нет.
### Что сделано иначе
* **Две фотографии аппаратов** (ЭкзоЛазер В и ЭкзоИмпульс) в исходном файле были
PNG на 1,5 МБ и 750 КБ — здесь они пережаты в webp, как и остальные снимки
оборудования. Исходники побайтово совпадают с медцентровским лендингом,
поэтому переиспользованы уже готовые webp-версии. Папка ассетов — 664 КБ
вместо 2,8 МБ.
* **Мёртвый CSS не переносился.** В оригинале лежат 39 правил
`.patient-economics` / `.base-economics-card`, на которые в разметке нет ни
одного элемента. Там же — обработчик анимации счётчиков, который ищет
`[data-counter]`; таких элементов на странице тоже нет.
* **Honeypot** получил визуально скрытую подпись — она нужна скринридерам и
скрыта утилитой `honeypot`, а не `display:none`, иначе поле перестанет быть
фокусируемым и ловушка не сработает.
### Что воспроизведено как есть
* Блок `.system-bottom` в секции «Готовое направление» сохраняет верхнюю
линию и отступы, хотя сетки карточек над ней в этом лендинге уже нет — так же
выглядит и оригинал.
* Мелкие подписи остались в исходных цветах: надзаголовки секций `#008F84` на
светлом фоне дают 3,79:1, источники цифр `#7E93A3` на белом — 3,19:1. Это
ниже 4,5:1, но соответствует оригиналу и остальным лендингам EXO; менять
палитру в одном лендинге не стали.
---
## Известные особенности
* `og:image` отдаётся по абсолютному адресу `VITE_SITE_URL` + `/og-image.webp`;
при смене домена обновите `VITE_SITE_URL` перед сборкой.
* Шапка прозрачна над первым экраном и получает размытую подложку после
18 px прокрутки — как в оригинале (в лендинге medcenter это правило было
перебито, и подложка там видна всегда).
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Физиотерапия с нуля для медицинского центра | Экзо Групп</title>
<meta
name="description"
content="Запуск физиотерапии под ключ для медицинского центра, где направления пока нет: аудит потока, проект кабинета, технологии ЭКЗО, клинические маршруты, обучение и пилот за 90 дней."
/>
<meta name="theme-color" content="#0A2540" />
<meta name="robots" content="index,follow" />
<link rel="canonical" href="%VITE_SITE_URL%" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="Экзо Групп" />
<meta property="og:title" content="Физиотерапия с нуля — готовое направление для медицинского центра" />
<meta
property="og:description"
content="Аудит, пространство, технологии, маршруты, обучение, запуск и контроль KPI — под существующий поток пациентов центра."
/>
<meta property="og:url" content="%VITE_SITE_URL%" />
<meta property="og:image" content="%VITE_SITE_URL%/og-image.webp" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Физиотерапия с нуля для медицинского центра — Экзо Групп" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Service",
"name": "Запуск физиотерапии с нуля для медицинского центра",
"provider": { "@type": "Organization", "name": "Экзо Групп" },
"serviceType": "Запуск направления физиотерапии в медицинском центре под ключ",
"areaServed": "RU",
"url": "https://exodevices.ru/medical-centers/no-physiotherapy"
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
MedCbezFiz — медицинский центр без физиотерапевтического кабинета
Готовый автономный лендинг.
Файл index.html содержит встроенные стили, JavaScript и изображения.
Для размещения загрузите index.html в нужную директорию сервера.
File diff suppressed because one or more lines are too long
+3661
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "exo-medcenter-no-physio-landing",
"private": true,
"version": "1.0.0",
"type": "module",
"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"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><rect width='128' height='128' rx='26' fill='#0A2540'/><path d='M35 28h33c21 0 36 14 36 35S89 99 68 99H35V28Zm17 17v37h15c11 0 19-8 19-19s-8-18-19-18H52Z' fill='#fff'/><circle cx='67' cy='63' r='6' fill='#00C4B4'/></svg>

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+66
View File
@@ -0,0 +1,66 @@
/**
* Verifies the amoCRM connection and prints what the integration will do with
* your account: which pipeline stage leads land in, and which of your custom
* fields the lead form's data was matched to.
*
* npm run amo:check
*/
import { AmoClient } from '../server/src/amocrm.ts'
import { readAmoConfig } from '../server/src/config.ts'
import { resolveLeadFieldMap } from '../server/src/lead-mapper.ts'
const config = readAmoConfig()
if (!config) {
console.error('✗ AMO_SUBDOMAIN and AMO_LONG_LIVED_TOKEN must be set in .env')
process.exit(1)
}
const amo = new AmoClient(config)
console.info(`${config.baseUrl}\n`)
const account = await amo.getAccount()
console.info(`✓ Account: ${account?.name} (id ${account?.id})`)
const pipeline = await amo.getPipeline(config.pipelineId)
if (!pipeline) {
console.error(`✗ Pipeline ${config.pipelineId} not found in this account.`)
process.exit(1)
}
const stages = [...(pipeline._embedded?.statuses ?? [])].sort((a, b) => a.sort - b.sort)
console.info(`✓ Pipeline ${pipeline.id}: «${pipeline.name}»`)
console.info(` Leads will be created in the first stage: «${stages[0]?.name ?? 'unknown'}»`)
console.info(` All stages: ${stages.map((s) => s.name).join(' → ')}\n`)
const leadFields = await amo.getLeadFields()
const map = resolveLeadFieldMap(leadFields)
console.info(`Lead data → amoCRM field mapping (${leadFields.length} custom fields in the account):`)
const keys = [
'company',
'profile',
'comment',
'page',
'referrer',
'form',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
]
for (const key of keys) {
const field = map.get(key)
console.info(
field
? `${key.padEnd(14)} → «${field.name}» (id ${field.id}, ${field.type})`
: ` · ${key.padEnd(14)} → note on the lead`,
)
}
const contactFields = await amo.getContactFields()
const hasPhone = contactFields.some((f) => f.code === 'PHONE')
const hasEmail = contactFields.some((f) => f.code === 'EMAIL')
console.info(`\nContact fields: PHONE ${hasPhone ? '✓' : '✗ missing'}, EMAIL ${hasEmail ? '✓' : '✗ missing'}`)
console.info('\nAll good — the form is ready to create leads.')
+247
View File
@@ -0,0 +1,247 @@
import type { AmoConfig } from './config.ts'
/* -------------------------------------------------------------------------- */
/* amoCRM REST types (only the parts this integration touches) */
/* -------------------------------------------------------------------------- */
export interface AmoEnum {
id: number
value: string
enum_code?: string | null
}
export interface AmoCustomField {
id: number
name: string
code: string | null
type: string
enums?: AmoEnum[] | null
}
interface AmoFieldValue {
value: string | number | boolean | null
enum_id?: number
enum_code?: string
}
export interface AmoFieldEntry {
field_id?: number
field_code?: string
values: AmoFieldValue[]
}
export interface AmoContact {
id: number
name?: string
custom_fields_values?: AmoFieldEntry[] | null
}
export interface AmoLead {
id: number
}
export class AmoError extends Error {
constructor(
message: string,
readonly status?: number,
readonly detail?: unknown,
) {
super(message)
this.name = 'AmoError'
}
}
/* -------------------------------------------------------------------------- */
/* Client */
/* -------------------------------------------------------------------------- */
const RETRYABLE = new Set([429, 500, 502, 503, 504])
export class AmoClient {
private leadFields?: Promise<AmoCustomField[]>
private contactFields?: Promise<AmoCustomField[]>
constructor(private readonly config: AmoConfig) {}
get baseUrl(): string {
return this.config.baseUrl
}
/**
* A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses
* for "found nothing" on every collection endpoint.
*/
private async request<T>(
path: string,
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
attempt = 1,
): Promise<T | null> {
const url = new URL(path, this.config.baseUrl)
for (const [key, value] of Object.entries(init.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
let response: Response
try {
response = await fetch(url, {
method: init.method ?? 'GET',
headers: {
Authorization: `Bearer ${this.config.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
signal: AbortSignal.timeout(15_000),
})
} catch (cause) {
if (attempt < 3) {
await sleep(attempt * 400)
return this.request<T>(path, init, attempt + 1)
}
throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`)
}
if (response.status === 204) return null
if (!response.ok) {
const detail = await response.text().catch(() => '')
if (RETRYABLE.has(response.status) && attempt < 3) {
await sleep(attempt * 700)
return this.request<T>(path, init, attempt + 1)
}
if (response.status === 401) {
throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail)
}
throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail)
}
return (await response.json()) as T
}
/* ---------------------------------------------------------------------- */
/* Field metadata — fetched once per process, then reused */
/* ---------------------------------------------------------------------- */
private async fetchFields(entity: 'leads' | 'contacts'): Promise<AmoCustomField[]> {
const collected: AmoCustomField[] = []
for (let page = 1; page <= 10; page++) {
const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>(
`/api/v4/${entity}/custom_fields`,
{ query: { page, limit: 250 } },
)
const batch = res?._embedded?.custom_fields ?? []
collected.push(...batch)
if (batch.length < 250) break
}
return collected
}
getLeadFields(): Promise<AmoCustomField[]> {
this.leadFields ??= this.fetchFields('leads')
return this.leadFields
}
getContactFields(): Promise<AmoCustomField[]> {
this.contactFields ??= this.fetchFields('contacts')
return this.contactFields
}
/* ---------------------------------------------------------------------- */
/* Contacts */
/* ---------------------------------------------------------------------- */
/** Full-text search. amoCRM matches phones and emails regardless of format. */
async findContact(queries: string[]): Promise<AmoContact | null> {
for (const query of queries) {
if (!query) continue
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
query: { query, limit: 1, with: 'leads' },
})
const contact = res?._embedded?.contacts?.[0]
if (contact) return contact
}
return null
}
async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise<AmoContact> {
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
method: 'POST',
body: [
{
name: input.name,
responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined,
},
],
})
const contact = res?._embedded?.contacts?.[0]
if (!contact) throw new AmoError('amoCRM did not return the created contact')
return contact
}
/** Adds phone/email to an existing contact without dropping what is there. */
async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise<void> {
if (!fields.length) return
await this.request(`/api/v4/contacts/${contactId}`, {
method: 'PATCH',
body: { custom_fields_values: fields },
})
}
/* ---------------------------------------------------------------------- */
/* Leads */
/* ---------------------------------------------------------------------- */
/**
* Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want.
*/
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] }
const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name }))
const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', {
method: 'POST',
body: [
{
name: input.name,
pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded,
},
],
})
const lead = res?._embedded?.leads?.[0]
if (!lead) throw new AmoError('amoCRM did not return the created lead')
return lead
}
async addLeadNote(leadId: number, text: string): Promise<void> {
await this.request(`/api/v4/leads/${leadId}/notes`, {
method: 'POST',
body: [{ note_type: 'common', params: { text } }],
})
}
/* ---------------------------------------------------------------------- */
/* Diagnostics */
/* ---------------------------------------------------------------------- */
async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> {
return this.request('/api/v4/account')
}
async getPipeline(id: number): Promise<{
id: number
name: string
_embedded?: { statuses?: { id: number; name: string; sort: number }[] }
} | null> {
return this.request(`/api/v4/leads/pipelines/${id}`)
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+58
View File
@@ -0,0 +1,58 @@
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') ?? 3000,
isProduction: process.env.NODE_ENV === 'production',
}
+112
View File
@@ -0,0 +1,112 @@
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()
app.set('trust proxy', true)
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, () => {
console.info(
'[server] listening on http://localhost:%d%s',
serverConfig.port,
serverConfig.isProduction ? ` (serving ${path.relative(rootDir, clientDir)})` : ' (API only — run vite for the UI)',
)
})
+149
View File
@@ -0,0 +1,149 @@
import type { LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** Human labels used in the fallback note. */
const LABELS: Record<string, string> = {
company: 'Медицинский центр',
email: 'Email',
phone: 'Телефон',
profile: 'Основные направления центра',
comment: 'Комментарий',
page: 'Страница',
referrer: 'Источник перехода',
form: 'Форма',
utm_source: 'utm_source',
utm_medium: 'utm_medium',
utm_campaign: 'utm_campaign',
utm_content: 'utm_content',
utm_term: 'utm_term',
}
/**
* Candidate amoCRM field codes / name fragments for each piece of data we
* collect. The first field in the account that matches wins; anything without
* a match falls through to the lead note instead.
*/
const CANDIDATES: Record<string, { codes: string[]; names: string[] }> = {
company: {
codes: ['COMPANY', 'COMPANY_NAME'],
names: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
},
profile: {
codes: ['PROFILE', 'SPECIALITY'],
names: ['профиль', 'основной профиль', 'направление', 'специализация'],
},
comment: { codes: ['COMMENT', 'MESSAGE', 'DESCRIPTION'], names: ['комментарий', 'сообщение', 'описание заявки'] },
page: { codes: ['REFERRER', 'PAGE'], names: ['страница', 'посадочная страница', 'url страницы'] },
referrer: { codes: ['REFERER_URL'], names: ['источник перехода', 'referrer', 'реферер'] },
form: { codes: ['FORMNAME', 'FORM_NAME'], names: ['название формы', 'форма'] },
utm_source: { codes: ['UTM_SOURCE'], names: ['utm_source'] },
utm_medium: { codes: ['UTM_MEDIUM'], names: ['utm_medium'] },
utm_campaign: { codes: ['UTM_CAMPAIGN'], names: ['utm_campaign'] },
utm_content: { codes: ['UTM_CONTENT'], names: ['utm_content'] },
utm_term: { codes: ['UTM_TERM'], names: ['utm_term'] },
}
/** Field types we know how to write a plain string into. */
const TEXTUAL = new Set(['text', 'textarea', 'url', 'tracking_data', 'numeric', 'price', 'monetary'])
const ENUMERATED = new Set(['select', 'radiobutton', 'multiselect'])
const normalise = (value: string) =>
value
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[^a-zа-я0-9]+/gi, ' ')
.trim()
export type LeadFieldMap = Map<string, AmoCustomField>
/** Picks one account field per payload key. A field is never used twice. */
export function resolveLeadFieldMap(fields: AmoCustomField[]): LeadFieldMap {
const map: LeadFieldMap = new Map()
const taken = new Set<number>()
for (const [key, candidate] of Object.entries(CANDIDATES)) {
const byCode = fields.find(
(f) => f.code && candidate.codes.includes(f.code.toUpperCase()) && !taken.has(f.id),
)
const match =
byCode ??
fields.find((f) => {
if (taken.has(f.id)) return false
const name = normalise(f.name)
return candidate.names.some((n) => name === normalise(n))
}) ??
fields.find((f) => {
if (taken.has(f.id)) return false
const name = normalise(f.name)
return candidate.names.some((n) => name.includes(normalise(n)))
})
if (!match) continue
if (!TEXTUAL.has(match.type) && !ENUMERATED.has(match.type)) continue
map.set(key, match)
taken.add(match.id)
}
return map
}
/** Builds one amoCRM field entry, or null when the value cannot be represented. */
function toFieldEntry(field: AmoCustomField, value: string): AmoFieldEntry | null {
if (ENUMERATED.has(field.type)) {
const target = normalise(value)
const option = field.enums?.find((e) => normalise(e.value) === target)
// An unmatched option would silently create garbage, so let it hit the note.
return option ? { field_id: field.id, values: [{ value: option.value, enum_id: option.id }] } : null
}
if (field.type === 'numeric' || field.type === 'price' || field.type === 'monetary') {
const numeric = Number(value.replace(/[^\d.,-]/g, '').replace(',', '.'))
return Number.isFinite(numeric) ? { field_id: field.id, values: [{ value: numeric }] } : null
}
return { field_id: field.id, values: [{ value }] }
}
export interface MappedLead {
fields: AmoFieldEntry[]
/** Everything that had no matching field, ready to be written as a note. */
note: string
}
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
const data: Record<string, string | undefined> = {
company: payload.company,
profile: payload.profile,
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на запуск физиотерапии с нуля',
...payload.utm,
}
const fields: AmoFieldEntry[] = []
const leftovers: string[] = []
for (const [key, value] of Object.entries(data)) {
if (!value) continue
const field = fieldMap.get(key)
const entry = field ? toFieldEntry(field, value) : null
if (entry) fields.push(entry)
else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
}
// Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card.
const header = [
'Заявка с лендинга «Физиотерапия с нуля для медицинского центра»',
`${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title shown in the pipeline. */
export function buildLeadName(payload: LeadPayload): string {
return `Физиотерапия — ${payload.company ?? payload.name}`
}
@@ -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),
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod'
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
export const leadFormIds = ['request'] as const
export type LeadFormId = (typeof leadFormIds)[number]
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.optional()
.transform((v) => (v ? v : undefined))
export const leadSchema = z.object({
form: z.enum(leadFormIds),
name: z.string().trim().min(1, 'Укажите имя').max(200),
phone: z.string().trim().min(5, 'Укажите телефон').max(50),
email: z
.union([z.email().max(200), z.literal('')])
.optional()
.transform((v) => (v ? v : undefined)),
company: optionalText(200),
/** «Основные направления центра» — one of the select's options, or free text. */
profile: optionalText(200),
comment: optionalText(4000),
page: optionalText(300),
referrer: optionalText(500),
// partialRecord, not record: z.record over an enum requires every key.
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
/** Honeypot. Bots fill it in; humans never see it. */
website: z.string().max(200).optional(),
})
export type LeadPayload = z.infer<typeof leadSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
export type LeadInput = z.input<typeof leadSchema>
export type LeadResponse =
| { ok: true; leadId: number; contactId: number }
| { ok: false; error: string; fields?: Record<string, string> }
+35
View File
@@ -0,0 +1,35 @@
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 { WhyExoSection } from './components/WhyExoSection'
export default function App() {
return (
<>
<SiteHeader />
<main>
<Hero />
<MarketSection />
<ScenariosSection />
<ProjectSection />
<ProgramsSection />
<EquipmentSection />
<LaunchSection />
<WhyExoSection />
<RequestSection />
</main>
<ResidencySection />
<SiteFooter />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,69 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
import { cx } from '../lib/cx'
export type ButtonVariant = 'primary' | 'secondary' | 'call'
const base =
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none whitespace-nowrap ' +
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
'[&>svg]:size-[18px] [&>svg]:shrink-0'
/**
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
* conflicting utilities by their order in the stylesheet, not by the order they
* appear in `className`, so a `min-h-[44px]` passed next to the default
* `min-h-[52px]` would silently lose. Replacing the slot means the losing class
* is never emitted in the first place.
*/
const defaultSize = 'min-h-[52px] px-[22px]'
/** `.btn--header` — smaller, and tighter still on the narrowest phones. */
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] phone:px-[14px] phone:text-[12px]'
const variants: Record<ButtonVariant, string> = {
primary:
'bg-teal text-navy shadow-[0_16px_45px_rgb(0_196_180/0.26)] hover:bg-teal-bright hover:shadow-[0_20px_55px_rgb(0_196_180/0.34)]',
secondary:
'border-white/[0.28] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.44] hover:bg-white/[0.14]',
call: 'border-white/[0.22] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.38] hover:bg-white/[0.14] [&>svg]:size-[16px]',
}
export function buttonClass(variant: ButtonVariant, className?: string, size: string = defaultSize) {
return cx(base, size, variants[variant], className)
}
interface CommonProps {
variant?: ButtonVariant
/** Replaces the default min-height/padding pair. See `defaultSize` above. */
size?: string
className?: string
children: ReactNode
}
export function Button({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size'>) {
return (
<button className={buttonClass(variant, className, size)} {...rest}>
{children}
</button>
)
}
export function ButtonLink({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a className={buttonClass(variant, className, size)} {...rest}>
{children}
</a>
)
}
@@ -0,0 +1,99 @@
import { devices, equipmentNote } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, ScrollHint, Section, SectionHead } from './Layout'
/**
* Product shots differ in framing, so the last stylesheet pass in the original
* sized 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="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования."
/>
<div
ref={track.ref}
aria-label="Оборудование Экзо Групп"
className={cx('scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)}
>
{devices.map((device) => (
<article
key={device.title}
className="equipment-card-surface flex min-h-[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>
))}
</div>
<ScrollHint tone="dark" className="md:hidden">
На мобильном листайте карточки в сторону
</ScrollHint>
<div
ref={note.ref}
className={cx(
'mt-[22px] flex items-start gap-[12px] rounded-md border border-teal/[0.26] bg-teal/[0.09] px-[20px] py-[18px] text-[13px] text-white/[0.76]',
'[&>svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright',
note.revealClass,
)}
>
<CheckIcon />
<span>{equipmentNote}</span>
</div>
</Container>
</Section>
)
}
@@ -0,0 +1,95 @@
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>
)
}
export function SelectField({
id,
label,
full,
placeholder,
options,
...rest
}: {
id: string
label: string
full?: boolean
placeholder: string
options: readonly string[]
} & ComponentPropsWithRef<'select'>) {
return (
<Field id={id} label={label} full={full}>
<select id={id} className={cx(controlClass, 'select-caret min-h-[52px] px-[15px]')} {...rest}>
{/* Options inherit the dark control colours in some browsers, so the
palette is restated on them. */}
<option value="" className="bg-white text-navy">
{placeholder}
</option>
{options.map((option) => (
<option key={option} value={option} className="bg-white text-navy">
{option}
</option>
))}
</select>
</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>
)
}
+72
View File
@@ -0,0 +1,72 @@
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 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]">
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
клинические маршруты, обучаем команду.
</p>
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] sm:flex-row sm:items-center">
<ButtonLink href="#proposal">
Получить предварительный аудит
<ArrowRightIcon />
</ButtonLink>
</div>
<div
aria-label="Ключевые параметры проекта"
className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:[&>*:last-child]:col-span-full"
>
{heroFacts.map((fact) => (
<div
key={fact.value}
className="min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]"
>
<strong className="mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]">
{fact.value}
</strong>
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
</div>
))}
</div>
<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,89 @@
import type { ReactNode } from 'react'
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
function Icon({ children }: { children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
{children}
</svg>
)
}
export function ArrowRightIcon() {
return (
<Icon>
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function CheckIcon() {
return (
<Icon>
<path d="m5 12 4 4L19 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.2" />
</Icon>
)
}
export function PhoneIcon() {
return (
<Icon>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.35 1.78.68 2.62a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.46-1.25a2 2 0 0 1 2.11-.45c.84.33 1.72.56 2.62.68A2 2 0 0 1 22 16.92Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</Icon>
)
}
/** 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,77 @@
import { formatNote, formats, timeline } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Section, SectionHead, SmallNote } from './Layout'
export function LaunchSection() {
const grid = useReveal<HTMLDivElement>()
const steps = useReveal<HTMLDivElement>()
return (
<Section id="launch" tone="white">
<Container>
<SectionHead
split
eyebrow="Пространство и запуск"
title="Начать можно с одного кабинета и расширяться после подтверждения KPI"
lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы."
/>
<div
ref={grid.ref}
className={cx('scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)}
>
{formats.map((format) => (
<article
key={format.area}
className="relative overflow-hidden rounded-[22px] border border-navy/[0.12] bg-white p-[24px]"
>
<span
aria-hidden="true"
className="absolute -top-[30px] -right-[30px] size-[110px] rounded-full bg-teal/[0.08]"
/>
<span className="mb-[9px] block text-[clamp(36px,5vw,52px)] leading-none font-[850] tracking-[-0.055em] text-navy">
{format.area}
</span>
<h3 className="text-[20px]">{format.title}</h3>
<p className="text-[14px] text-muted">{format.text}</p>
</article>
))}
</div>
<SmallNote className="mt-[16px]">{formatNote}</SmallNote>
{/* The timeline breaks into two columns at 900px, not at the shared
760px step the rest of the page uses. */}
<div
ref={steps.ref}
className={cx(
'scroll-cards mt-[30px] auto-cols-[minmax(330px,92%)] gap-[14px] min-[900px]:grid-still min-[900px]:grid-cols-2',
steps.revealClass,
)}
>
{timeline.map((step) => (
<article
key={step.week}
className="rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[22px]"
>
<span className="mb-[8px] block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">
{step.week}
</span>
<strong className="mb-[10px] block text-[18px] leading-[1.2] text-navy">{step.title}</strong>
<span className="mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span>
<ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted">
{step.items.map((item) => (
<li key={item.title}>
<strong className="inline text-[13px] text-navy">{item.title}</strong>
{item.text ? `: ${item.text}` : null}
</li>
))}
</ul>
</article>
))}
</div>
</Container>
</Section>
)
}
+169
View File
@@ -0,0 +1,169 @@
import type { ReactNode } from 'react'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
type Tone = 'light' | 'dark'
/** `.container` — 1200px max, 16px gutters that grow to 24px from 760px up. */
export function Container({ className, children }: { className?: string; children: ReactNode }) {
return (
<div
className={cx(
'mx-auto w-[min(calc(100%_-_32px),var(--container-page))] md:w-[min(calc(100%_-_48px),var(--container-page))]',
className,
)}
>
{children}
</div>
)
}
/** `.eyebrow` — small uppercase kicker with the leading teal rule. */
export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<p
className={cx(
'mb-[16px] inline-flex items-center gap-[10px] text-[12px] font-[850] tracking-[0.14em] uppercase',
"before:h-[2px] before:w-[27px] before:rounded-[2px] before:bg-teal before:content-['']",
tone === 'dark' ? 'text-teal-bright' : 'text-teal-ink',
className,
)}
>
{children}
</p>
)
}
/** `.lead` — the paragraph under a section heading. */
export function SectionLead({
tone = 'light',
className,
children,
}: {
tone?: Tone
className?: string
children: ReactNode
}) {
return (
<p
className={cx(
'mb-0 max-w-[780px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
className,
)}
>
{children}
</p>
)
}
/** `.small-note` — the fine print under a section. */
export function SmallNote({
tone = 'light',
className,
children,
}: {
tone?: Tone
className?: string
children: ReactNode
}) {
return (
<p className={cx('text-[13px] leading-[1.55]', tone === 'dark' ? 'text-white/[0.56]' : 'text-muted', className)}>
{children}
</p>
)
}
/**
* `.section-head` — stacked by default; `split` moves the lead into a second
* column from 760px up, bottom-aligned against the heading.
*/
export function SectionHead({
eyebrow,
title,
lead,
tone = 'light',
split = false,
}: {
eyebrow: string
title: ReactNode
lead?: ReactNode
tone?: Tone
split?: boolean
}) {
const { ref, revealClass } = useReveal<HTMLDivElement>()
if (!split) {
return (
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2>{title}</h2>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
return (
<div
ref={ref}
className={cx(
'mb-[clamp(34px,5vw,54px)] grid gap-[20px] md:grid-cols-[minmax(0,1.15fr)_minmax(300px,0.85fr)] md:items-end',
revealClass,
)}
>
<div>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2 className="mb-0">{title}</h2>
</div>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
const sectionTones = {
light: 'bg-paper',
white: 'bg-white',
dark: 'bg-navy text-white',
/** For sections that paint their own multi-layer background in index.css. */
none: '',
}
/** `.section` — the shared vertical rhythm and background variants. */
export function Section({
id,
tone = 'light',
compact = false,
className,
children,
}: {
id?: string
tone?: keyof typeof sectionTones
compact?: boolean
className?: string
children: ReactNode
}) {
return (
<section
id={id}
className={cx('relative', compact ? 'py-section-compact' : 'py-section', sectionTones[tone], className)}
>
{children}
</section>
)
}
/** `.scroll-hint` — only meaningful while the card row still scrolls sideways. */
export function ScrollHint({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<div
aria-hidden="true"
className={cx(
"mt-[12px] flex items-center gap-[8px] text-[11px] before:h-px before:w-[24px] before:bg-current before:content-['']",
tone === 'dark' ? 'text-white/[0.48]' : 'text-muted',
className,
)}
>
{children}
</div>
)
}
@@ -0,0 +1,171 @@
import { useState, type FormEvent } from 'react'
import { profileOptions } from '../data/content'
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, SelectField, TextField, TextareaField } from './FormField'
import { ArrowRightIcon } from './Icons'
const EMPTY = {
name: '',
company: '',
phone: '',
email: '',
profile: '',
comment: '',
}
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
export function LeadForm() {
const { ref: formRef, revealClass } = useReveal<HTMLFormElement>()
const [values, setValues] = useState(EMPTY)
const [honeypot, setHoneypot] = useState('')
const [pending, setPending] = useState(false)
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
const set = (key: keyof typeof EMPTY) => (event: { target: { value: string } }) =>
setValues((current) => ({ ...current, [key]: event.target.value }))
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = formRef.current
if (!form) return
if (honeypot) return
if (!form.checkValidity()) {
form.reportValidity()
setStatus({ text: 'Проверьте обязательные поля формы.', tone: 'error' })
return
}
if (!isCompleteRuPhone(values.phone)) {
setStatus({ text: 'Укажите полный номер телефона.', tone: 'error' })
form.phone.focus()
return
}
setPending(true)
setStatus({ text: '', tone: 'idle' })
const result = await submitLead('request', { ...values, website: honeypot })
if (result.ok) {
setValues(EMPTY)
setStatus({
text: 'Спасибо. Заявка отправлена — специалист свяжется с вами для предварительного аудита.',
tone: 'success',
})
} else {
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
}
setPending(false)
}
return (
<form
ref={formRef}
id="leadForm"
noValidate
onSubmit={onSubmit}
className={cx(
'rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]',
revealClass,
)}
>
<div className="grid gap-[12px] sm:grid-cols-2">
<TextField
id="name"
label="Имя *"
name="name"
autoComplete="name"
placeholder="Как к вам обращаться"
required
value={values.name}
onChange={set('name')}
/>
<TextField
id="company"
label="Компания / медицинский центр *"
name="company"
autoComplete="organization"
placeholder="Название центра"
required
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"
required
value={values.email}
onChange={set('email')}
/>
<SelectField
id="profile"
label="Основные направления центра"
name="profile"
full
placeholder="Выберите профиль"
options={profileOptions}
value={values.profile}
onChange={set('profile')}
/>
<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,121 @@
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'
export function MarketSection() {
const stats = useReveal<HTMLDivElement>()
const gap = useReveal<HTMLElement>()
return (
<Section id="market">
<Container>
<SectionHead
split
eyebrow="Рыночный контекст"
title="Спрос на реабилитацию растёт. Пациент уже находится внутри вашего центра"
lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли."
/>
<div className="grid gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
<div
ref={stats.ref}
className={cx(
'scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2',
stats.revealClass,
)}
>
{marketStats.map((stat) => (
<article
key={stat.label}
className="relative min-h-[185px] overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
phone:min-h-[172px] phone:p-[20px] sm:min-h-[205px]"
>
<span
aria-hidden="true"
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full"
/>
<strong className="mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
{stat.value}
</strong>
<p className="max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
<span className="absolute bottom-[19px] left-[24px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase phone:bottom-[15px] phone:left-[20px]">
{stat.source}
</span>
</article>
))}
</div>
<article
ref={gap.ref}
className={cx(
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep',
gap.revealClass,
)}
>
<span
aria-hidden="true"
className="teal-bloom absolute -right-[160px] -bottom-[200px] size-[480px] rounded-full"
/>
<div className="relative z-1">
<Eyebrow tone="dark">Исходная точка</Eyebrow>
<h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)]">
Маршрут пациента: как перестать отдавать прибыль конкурентам.
</h3>
</div>
<div className="relative z-1 grid gap-[12px] md: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,79 @@
import { programs, routeSteps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, ScrollHint, Section, SectionHead } from './Layout'
export function ProgramsSection() {
const route = useReveal<HTMLElement>()
return (
<Section id="programs" compact>
<Container>
<SectionHead
eyebrow="Курсовая модель"
title="Новая услуга сразу запускается как маршрут, а не набор разовых процедур"
lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель."
/>
<div 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} />
))}
</div>
<ScrollHint className="md:hidden">На мобильном листайте программы</ScrollHint>
<aside
ref={route.ref}
className={cx(
'mt-[24px] rounded-xl bg-navy p-[clamp(24px,4vw,36px)] text-white shadow-deep md:mt-[18px]',
route.revealClass,
)}
>
<Eyebrow tone="dark">Маршрут пациента</Eyebrow>
<h3>Кабинет загружается текущим потоком, а не ожиданием случайных обращений</h3>
<div className="mt-[22px] grid gap-[9px] md:grid-cols-5 md:gap-[8px]">
{routeSteps.map((step, index) => (
<div
key={step.title}
className="grid grid-cols-[42px_1fr] items-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'
export function ProjectSection() {
const strip = useReveal<HTMLDivElement>()
const panel = useReveal<HTMLDivElement>()
return (
<Section id="project" tone="white">
<Container>
<SectionHead
eyebrow="Готовое направление"
title="Не закупка аппаратов, а работающая система физиотерапии"
lead={
<>
Каждый шаг нашей дорожной карты нужен для того, чтобы кабинет приносил реальные деньги, а не просто
числился на балансе клиники. «<strong>Экзо</strong> Групп» комплексно решает задачи медицинского и
операционного запуска.
</>
}
/>
<div
ref={strip.ref}
className={cx(
'scroll-cards mb-[24px] auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:mb-[28px] sm:grid-cols-2 md:grid-cols-4',
strip.revealClass,
)}
>
{promises.map((promise) => (
<article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]">
<div className="mb-[14px] grid size-[42px] place-items-center rounded-[13px] bg-ice text-navy [&>svg]:size-[22px]">
{promiseIcons[promise.icon]}
</div>
<h3 className="text-[20px]">{promise.title}</h3>
<p className="text-[14px] text-muted">{promise.text}</p>
</article>
))}
</div>
<div
ref={panel.ref}
className={cx(
'system-panel-surface rounded-xl border border-navy/[0.12] p-[clamp(24px,4vw,40px)] shadow-[0_22px_70px_rgb(4_30_48/0.08)]',
panel.revealClass,
)}
>
{/* `.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,39 @@
import { ctaChecks } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, Eyebrow, SectionLead } from './Layout'
import { LeadForm } from './LeadForm'
export function RequestSection() {
const copy = useReveal<HTMLDivElement>()
return (
<section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white">
<Container className="relative grid items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
<div ref={copy.ref} className={cx(copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="text-[clamp(39px,6vw,68px)]">Запустим физиотерапию с нуля под ваш поток</h2>
<SectionLead tone="dark">
Оставьте контакты специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего
подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
</SectionLead>
<ul className="mt-[25px] grid gap-[10px]">
{ctaChecks.map((check) => (
<li
key={check}
className="flex gap-[10px] text-[14px] text-white/[0.75] [&>svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright"
>
<CheckIcon />
<span>{check}</span>
</li>
))}
</ul>
</div>
<LeadForm />
</Container>
</section>
)
}
@@ -0,0 +1,53 @@
import skolkovo from '../assets/images/resident-skolkovo.svg'
import zhiguli from '../assets/images/resident-zhiguli.svg'
import { residencies } from '../data/content'
const logos = [zhiguli, skolkovo]
export function ResidencySection() {
return (
<section
aria-label="Резидентские статусы Экзо Групп"
className="residency-surface relative isolate overflow-hidden border-t border-white/[0.08] py-[34px] pb-[16px] text-white md:pt-[42px] md:pb-[20px]"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-[200px] -right-[120px] -z-1 size-[360px] rounded-full bg-teal/[0.16] blur-[80px]"
/>
<div className="mx-auto flex w-[min(calc(100%_-_32px),var(--container-page))] flex-col gap-[20px] md:w-[min(calc(100%_-_48px),var(--container-page))] md:flex-row md:items-center md:justify-between md:gap-[34px]">
<div className="grid max-w-[580px] gap-[6px]">
<span className="text-[10px] leading-[1.2] font-[850] tracking-[0.16em] text-teal">
ИННОВАЦИОННАЯ ЭКОСИСТЕМА
</span>
<strong className="text-[clamp(18px,2.6vw,27px)] leading-[1.15] tracking-[-0.02em] text-white">
Экзо Групп резидент ведущих технологических площадок
</strong>
</div>
<div className="grid w-[min(100%,540px)] grid-cols-2 gap-[11px] compact:grid-cols-1 md:flex-[0_0_min(50%,540px)]">
{residencies.map((residency, index) => (
<a
key={residency.href}
aria-label={residency.label}
className="flex min-h-[82px] min-w-0 items-center justify-center rounded-md border border-white/[0.75] bg-white/[0.97] px-[16px] py-[12px]
shadow-[0_16px_40px_rgb(0_0_0/0.13)] transition-[transform,box-shadow,border-color] duration-200
hover:-translate-y-[3px] hover:border-teal/[0.72] hover:shadow-[0_20px_46px_rgb(0_0_0/0.2)] compact:min-h-[78px]"
href={residency.href}
rel="noopener noreferrer"
target="_blank"
>
<img
alt={residency.label}
className="block h-auto max-h-[58px] w-auto max-w-full object-contain compact:max-h-[54px]"
src={logos[index]}
width={residency.width}
height={residency.height}
/>
</a>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,81 @@
import { revenueDrivers, revenueScenarios } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
/**
* `.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]">
Даже умеренная загрузка кабинета даёт понятную выручку
</h2>
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72]">
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
срок окупаемости.
</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>
<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,28 @@
import logo from '../assets/images/exo-logo.png'
import { footerLegal } from '../data/content'
import { Container } from './Layout'
export function SiteFooter() {
return (
<footer className="bg-footer py-[26px] text-[12px] text-white/[0.52]">
<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,58 @@
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. */
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',
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:px-[12px] [&>svg]:phone:size-[18px]', headerSize)}
>
<PhoneIcon />
<span className="phone:hidden">Звонок</span>
</a>
<ButtonLink href="#proposal" size={headerSize}>
Получить предложение
</ButtonLink>
</div>
</Container>
</header>
)
}
@@ -0,0 +1,79 @@
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'
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">
<div
ref={list.ref}
className={cx('scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still', list.revealClass)}
>
{whyItems.map((item) => (
<div
key={item.title}
className="grid grid-cols-[44px_1fr] gap-[14px] rounded-[19px] border border-navy/[0.12] bg-white p-[18px]"
>
<span className="grid size-[44px] place-items-center rounded-[14px] bg-ice text-teal-ink [&>svg]:size-[22px]">
{whyIcons[item.icon]}
</span>
<div>
<strong className="mb-[4px] block text-[16px] text-navy">{item.title}</strong>
<span className="block text-[13px] text-muted">{item.text}</span>
</div>
</div>
))}
</div>
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] 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>
)
}
+457
View File
@@ -0,0 +1,457 @@
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: '#project', label: 'Что запускаем' },
{ href: '#technology', label: 'Технологии' },
{ href: '#launch', label: 'Запуск' },
]
/* -------------------------------------------------------------------------- */
/* Первый экран */
/* -------------------------------------------------------------------------- */
export const heroFacts = [
{ value: 'от 12 м²', label: 'стартовый формат одного кабинета' },
{ value: '90 дней', label: 'до контрольной точки пилота' },
{
value: '35% к чистой прибыли',
label: 'превращаем кабинет физиотерапии в главный источник дохода клиники',
},
]
/* -------------------------------------------------------------------------- */
/* Рыночный контекст */
/* -------------------------------------------------------------------------- */
export const marketStats = [
{
value: '2,41',
label: 'млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация',
source: 'WHO / IHME',
},
{ value: '+63%', label: 'рост глобальной потребности в реабилитации с 1990 года', source: 'WHO / IHME' },
{
value: '1,0 → 1,4',
label: 'млн пациентов медицинской реабилитации в России, 20202024',
source: 'BusinesStat',
},
{
value: '5,1%',
label: 'прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 20262033',
source: 'Grand View Research',
},
]
/** «Пока физиотерапии нет» — левая колонка карточки маршрута. */
export const routeWithout = [
'пациент ищет процедуры в другом месте;',
'у врача нет аппаратного этапа комплексной программы;',
'нет отдельной линейки курсов и пакетов;',
'часть повторных визитов и выручки остаётся вне центра.',
]
/** «После запуска направления» — правая, акцентная колонка. */
export const routeWith = [
'пациент проходит маршрут внутри одной клиники;',
'назначение, процедуры и контроль связаны в курс;',
'существующая база формирует стартовую загрузку;',
'руководитель видит конверсию, завершение и экономику.',
]
export const marketNotes = [
'Рыночные показатели приведены по WHO/IHME, BusinesStat и Grand View Research. Они описывают рынок и потребность, но не являются прогнозом загрузки конкретного медицинского центра.',
'CAGR (Compound Annual Growth Rate) — среднегодовой темп роста: показатель, который показывает, каким был бы постоянный средний процент роста рынка за каждый год выбранного периода.',
]
/* -------------------------------------------------------------------------- */
/* Сценарии выручки */
/* -------------------------------------------------------------------------- */
export const revenueScenarios = [
{ courses: '30', revenue: '0,81 млн ₽', note: 'осторожный сценарий' },
{ courses: '60', revenue: '1,62 млн ₽', note: 'рабочая загрузка' },
{ courses: '100', revenue: '2,70 млн ₽', note: 'сильная загрузка' },
]
export const revenueDrivers = ['Загрузка кабинета', 'Доля курсов', 'Повторные визиты', 'Средний чек']
/* -------------------------------------------------------------------------- */
/* Готовое направление */
/* -------------------------------------------------------------------------- */
export const promises = [
{
icon: 'retain',
title: 'Удержать пациента',
text: 'Добавить недостающий аппаратный этап в маршрут лечения и реабилитации внутри центра.',
},
{
icon: 'revenue',
title: 'Создать новую выручку',
text: 'Запустить отдельную платную линейку процедур, курсов и комплексных программ.',
},
{
icon: 'clinical',
title: 'Усилить врачебный продукт',
text: 'Связать назначение врача, технологии, длительность курса и контроль динамики.',
},
{
icon: 'metrics',
title: 'Управлять показателями',
text: 'Измерять назначения, загрузку, завершение курса, повторные визиты и экономику.',
},
] as const
export const systemStatement =
'Сначала выбирается клиническая задача. Затем — технология и формат кабинета.'
export const profileChipsNote = 'Профили, для которых особенно актуален предварительный аудит:'
export const profileChips = [
'Травматология и ортопедия',
'Неврология',
'Послеоперационная реабилитация',
'Спортивная медицина',
'Ревматология',
'Флебология / лимфология',
'Гинекология / Урология',
'Эстетическая медицина',
]
/* -------------------------------------------------------------------------- */
/* Технологическое ядро */
/* -------------------------------------------------------------------------- */
export const devices = [
{
image: deviceTherapy,
alt: 'Аппарат ЭкзоТерапия',
kicker: 'Системное усиление',
title: 'ЭкзоТерапия',
indications: 'ОДА • неврология • дыхательная система • сосуды • органы малого таза • ЖКТ',
role: 'Высокоинтенсивная импульсная магнитотерапия.',
},
{
image: deviceTecar,
alt: 'Аппарат ЭкзоТекар 3 в 1',
kicker: 'Локальное ядро',
title: 'ЭкзоТекар 3 в 1',
indications: 'мышцы • суставы • связки • сухожилия • фасции • рубцы • посттравматическое восстановление',
role: 'Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.',
},
{
image: deviceMagnet,
alt: 'Аппарат ЭкзоМагнит 3 в 1',
kicker: 'Многофакторный модуль',
title: 'ЭкзоМагнит 3 в 1',
indications: 'суставы • позвоночник • мышцы • сухожилия • мягкие ткани • малый таз',
role: 'Магнит высокой интенсивности + ударно-волновая терапия + инфракрасная терапия.',
},
{
image: devicePress,
alt: 'Аппарат ЭкзоПресс',
kicker: 'Параллельный поток',
title: 'ЭкзоПресс',
indications:
'отёчные программы • лимфоотток • снятие тяжести в ногах • сосуды • послеоперационная реабилитация',
role: 'Компрессионная терапия + тепловая терапия + холодовая терапия.',
},
{
image: deviceHydrogen,
alt: 'Аппарат ЭкзоВодород',
kicker: 'Поддерживающий контур',
title: 'ЭкзоВодород',
indications:
'системное восстановление • антиоксидантная терапия • улучшение метаболизма • поддержка иммунитета • антистресс',
role: 'Ингаляция молекулярным водородом',
},
{
image: deviceLaser,
alt: 'Аппарат ЭкзоЛазер B',
kicker: 'Локальные программы',
title: 'ЭкзоЛазер В',
indications:
'мягкотканные травмы • точечное воспаление • регенерация тканей • болевые синдромы • реабилитация • стимуляция микроциркуляции • противоотёчный эффект • лечение связок и сухожилий • ускорение метаболизма',
role: 'Высокоинтенсивная лазерная терапия.',
},
{
image: deviceImpulse,
alt: 'Аппарат ЭкзоИмпульс',
kicker: 'Нейромышечный модуль',
title: 'ЭкзоИмпульс',
indications:
'электромагнитная стимуляция • нервно-мышечная система • устранение спазмов и зажимов • активация регенерации • противовоспалительный эффект • микроциркуляция и лимфодренаж • снятие глубоких мышечных болей • восстановление двигательной активности • реабилитация при неврологических патологиях',
role: 'Фокусированная ударно-волновая терапия',
},
]
export const equipmentNote =
'Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.'
/* -------------------------------------------------------------------------- */
/* Курсовая модель */
/* -------------------------------------------------------------------------- */
export const programs = [
{
num: '01',
title: 'Нейрореабилитация',
text: 'Наше оборудование помогает пациентам восстановить двигательные функции, в т.ч. после инсульта, снизить проявления спастичности и парезов, улучшить баланс и координацию.',
},
{
num: '02',
title: 'Травматология и ортопедия',
text: 'Устройства обеспечивают эффективную реабилитацию после переломов и эндопротезирования, способствуют снятию отёка и боли, повышают подвижность суставов и предупреждают развитие контрактур.',
},
{
num: '03',
title: 'Послеоперационное восстановление',
text: 'Оборудование позволяет активизировать пациентов в ранние сроки, стимулирует регенерацию тканей и снижает риск послеоперационных осложнений.',
},
{
num: '04',
title: 'Хронические боли и дегенеративные заболевания',
text: 'При остеохондрозе и артрозах оборудование помогает снизить болевой синдром и улучшить трофику и подвижность поражённых участков.',
},
{
num: '05',
title: 'Детская реабилитация',
text: 'Специальные методики способствуют развитию моторных навыков у детей с ДЦП, укрепляют мышечный корсет и помогают освоить вертикальное положение.',
},
{
num: '06',
title: 'Спортивная медицина',
text: 'Оборудование ускоряет восстановление спортсменов после травм и перегрузок, помогает грамотно вернуться к тренировкам и соревнованиям.',
},
{
num: '07',
title: 'Паллиативная поддержка',
text: 'Устройства облегчают хроническую боль и помогают сохранить функциональную активность на максимально возможном уровне.',
},
]
export const routeSteps = [
{ title: 'Первичный приём', text: 'Цели, ограничения, противопоказания и исходные показатели.' },
{ title: 'Назначение курса', text: 'Выбор маршрута и технологий в пределах документации.' },
{ title: 'Процедуры', text: 'Выполнение по назначению, карте и утверждённому регламенту.' },
{
title: 'Контроль динамики',
text: 'Выбранные клинические показатели, переносимость и завершение курса.',
},
{ title: 'Повторный приём', text: 'Оценка результата, корректировка и следующий этап по показаниям.' },
]
/* -------------------------------------------------------------------------- */
/* Пространство и запуск */
/* -------------------------------------------------------------------------- */
export const formats = [
{
area: 'от 12 м²',
title: 'Один кабинет',
text: 'Стартовый формат под отдельный сценарий и ограниченную конфигурацию.',
},
{
area: 'от 35 м²',
title: 'Два кабинета',
text: 'Базовая инфраструктура с разделением активных и параллельных процедур.',
},
{
area: 'от 45 м²',
title: 'Три кабинета',
text: 'Расширенный формат для нескольких маршрутов и большей пропускной способности.',
},
]
export const formatNote =
'Финальный метраж, зонирование и состав фиксируются только после аудита помещения и медицинской модели.'
export const timeline = [
{
week: '12 неделя.',
title: 'Сбор данных и предпроектный анализ потенциала',
lead: 'Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы.',
items: [
{
title: 'Анализ действующей базы и потока',
text: 'Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.',
},
{
title: 'Аудит помещений и эргономики',
text: 'Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.',
},
{
title: 'Экспертиза лицензирования',
text: 'Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.',
},
{
title: 'Оценка команды и выбор маршрутов',
text: 'Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.',
},
],
},
{
week: '35 неделя.',
title: 'Разработка бизнес-проекта и EXO-интеграции',
lead: 'Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ.',
items: [
{
title: 'Создание концепции направления',
text: 'Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.',
},
{
title: 'Подбор оптимального состава',
text: 'Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.',
},
{
title: 'Проектирование планировки и сан-логистики',
text: 'Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.',
},
{
title: 'Юридическое сопровождение',
text: 'Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.',
},
{
title: 'Расчет экономики и плана пилота',
text: 'Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.',
},
],
},
{
week: '68 неделя.',
title: 'Бесшовная подготовка, оснащение и обучение команды',
lead: 'Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса.',
items: [
{
title: 'Поставка и шеф-монтаж',
text: 'Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.',
},
{
title: 'Обучение врачей с нуля',
text: 'Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.',
},
{
title: 'Практика медсестер и администраторов',
text: 'Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.',
},
{
title: 'Внедрение процедурных карт',
text: 'Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.',
},
{
title: 'Настройка записи и расписания',
text: 'Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.',
},
],
},
{
week: '912 неделя.',
title: 'Запуск пилотного проекта и выход на плановую прибыль',
lead: 'Тестовый запуск направления на вашей базе, контроль стандартов сервиса и получение первой коммерческой выручки.',
items: [
{
title: 'Запуск назначений и продаж',
text: 'Перевод первых целевых пациентов из вашей действующей базы на новые EXO-курсы вместо разовых визитов.',
},
{
title: 'Контроль медицинских и бизнес-показателей',
text: 'Сквозной мониторинг конверсии из приема в покупку курса, отслеживание динамики лечения пациентов по шкалам «до / после».',
},
{
title: 'Разбор отклонений и адаптация',
text: 'Оперативная корректировка работы персонала, разбор сложных клинических случаев и донастройка скриптов администраторов нашими экспертами.',
},
{
title: 'Внедрение премиального сервиса',
text: 'Контроль удовлетворенности пациентов для формирования лояльной базы и запуска сарафанного радио.',
},
],
},
]
/* -------------------------------------------------------------------------- */
/* Почему Экзо Групп */
/* -------------------------------------------------------------------------- */
export const whyItems = [
{
icon: 'production',
title: 'Собственная разработка и сервис',
text: 'Линейка высокоинтенсивных и восстановительных технологий внутри одного продуктового контура.',
},
{
icon: 'clinics',
title: 'Сеть Экзо Клиник',
text: 'Оборудование, маршруты и организация работы проверяются на реальных медицинских процессах.',
},
{
icon: 'shield',
title: 'Полный контур внедрения',
text: 'Проект, оборудование, клиническая логика, обучение, сервис, продажи, KPI и масштабирование.',
},
] as const
export const photoCaption = {
title: 'Российское производство',
text: 'Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».',
}
export const companyStats = [
{ value: '4', label: 'собственные клиники' },
{ value: '70+', label: 'партнёрских клиник' },
{ value: '60+', label: 'медицинских учреждений' },
{ value: '2 млн+', label: 'проведённых процедур' },
]
export const companyStatsNote =
'Количественные показатели — по корпоративным материалам Экзо Групп; перед массовой внешней публикацией рекомендуется финальная сверка.'
/* -------------------------------------------------------------------------- */
/* Заявка */
/* -------------------------------------------------------------------------- */
export const ctaChecks = [
'предварительный аудит базы и врачебных направлений;',
'варианты пространства и состава технологий;',
'план запуска на 90 дней и набор контрольных KPI.',
]
export const profileOptions = [
'Многопрофильный медицинский центр',
'Ортопедия / неврология',
'Реабилитация / ЛФК / спорт',
'Флебология',
'Женское / мужское здоровье',
'Кардиология / пульмонология',
'Онкореабилитация',
'Другое',
]
/* -------------------------------------------------------------------------- */
/* Подвал и резидентские статусы */
/* -------------------------------------------------------------------------- */
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 },
]
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react'
/**
* Scroll-in animation, matching the original page: fires once at 12% visibility
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
* that `prefers-reduced-motion` can neutralise it in one place.
*/
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element || visible) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
setVisible(true)
observer.unobserve(entry.target)
}
},
{ threshold: 0.12 },
)
observer.observe(element)
return () => observer.disconnect()
}, [visible])
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
}
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
/**
* The header gains its blurred background as soon as the page leaves the very
* top — 18px, same threshold as the original landing.
*/
export function useScrolled(threshold = 18) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [threshold])
return scrolled
}
+320
View File
@@ -0,0 +1,320 @@
@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);
}
.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%);
}
/* The <select> arrow: two triangles drawn with gradients, as in the original
(no background-image utility can express this pair). */
.select-caret {
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgb(255 255 255 / 0.7) 50%),
linear-gradient(135deg, rgb(255 255 255 / 0.7) 50%, transparent 50%);
background-position:
calc(100% - 18px) 22px,
calc(100% - 13px) 22px;
background-size: 5px 5px;
background-repeat: no-repeat;
}
}
/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
breakpoint prefix, because sections switch over at different widths. */
@utility grid-still {
grid-auto-flow: row;
grid-auto-columns: auto;
overflow: visible;
padding: 0;
margin-inline: 0;
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* Honeypot: must stay focusable-but-invisible, so `hidden` is not an option. */
@utility honeypot {
position: absolute !important;
left: -9999px !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
.reveal {
opacity: 1;
transform: none;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cx(...values: (string | false | null | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
+80
View File
@@ -0,0 +1,80 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
const ENDPOINT = '/api/leads/medical-centers-no-physio'
const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft'
const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of utmKeys) {
const value = params.get(key)
if (value) utm[key] = value
}
return utm
}
export interface SubmitResult {
ok: boolean
error?: string
fields?: Record<string, string>
}
export async function submitLead(
form: LeadFormId,
values: Omit<LeadInput, 'form' | 'utm' | 'page' | 'referrer'>,
): Promise<SubmitResult> {
const payload: LeadInput = {
...values,
form,
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = (await response.json().catch(() => null)) as LeadResponse | null
if (!response.ok || !body?.ok) {
// The page must never lose a lead just because the CRM is down.
saveDraft(payload)
return {
ok: false,
error: body && !body.ok ? body.error : FALLBACK_ERROR,
fields: body && !body.ok ? body.fields : undefined,
}
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_no_physio_lead_sent', form })
localStorage.removeItem(DRAFT_KEY)
return { ok: true }
} catch (error) {
saveDraft(payload)
console.warn('Lead endpoint error', error, payload)
return { ok: false, error: FALLBACK_ERROR }
}
}
function saveDraft(payload: LeadInput) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
} catch {
// Private-mode browsers throw on write; the visible error message is enough.
}
}
declare global {
interface Window {
dataLayer?: Record<string, unknown>[]
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Progressive `+7 900 000-00-00` mask, matching the original page's input
* handler. Returns the input untouched until the visitor has typed a digit, so
* that clearing the field is still possible.
*/
export function formatRuPhone(raw: string): string {
let digits = raw.replace(/\D/g, '').slice(0, 11)
if (!digits) return ''
if (digits[0] === '8') digits = `7${digits.slice(1)}`
if (digits[0] !== '7') digits = `7${digits}`
let value = '+7'
if (digits.length > 1) value += ` ${digits.slice(1, 4)}`
if (digits.length >= 5) value += ` ${digits.slice(4, 7)}`
if (digits.length >= 8) value += `-${digits.slice(7, 9)}`
if (digits.length >= 10) value += `-${digits.slice(9, 11)}`
return value
}
/** The original refused to submit before all eleven digits were entered. */
export const isCompleteRuPhone = (value: string) => value.replace(/\D/g, '').length >= 11
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Self-hosted so the landing stays self-contained and makes no third-party
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
// cyrillic are actually downloaded for this page.
import '@fontsource-variable/inter'
import App from './App'
import './index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root is missing from index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src", "shared"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo"
},
"include": ["server/src", "shared", "scripts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
// Set VITE_BASE_PATH=/medical-centers/no-physiotherapy/ when the site is
// mounted on a sub-path, so asset URLs in the built HTML resolve against it.
base: process.env.VITE_BASE_PATH ?? '/',
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist/client',
emptyOutDir: true,
assetsInlineLimit: 0,
},
server: {
// 5173/3000 and 5174/3001 belong to the fitnes/medcenter and hotel
// landings, so all of them can run side by side.
port: 5175,
proxy: {
'/api': { target: 'http://localhost:3002', changeOrigin: true },
},
},
})