Convert medical-centre landing to React + Tailwind v4 with amoCRM lead capture

Ports the single-file MedCsFiz page — the medical centre that already runs
physiotherapy and wants the existing room to earn more — to the same
architecture as the fitness and hotel landings: Vite + React 19 + TypeScript +
Tailwind v4 on the client, Express 5 for the API, zod schema shared between the
two.

The original stays in legacy/index.html as the visual reference. Its 4 MB of
inlined CSS, JS and base64 images become 12 asset files (664 KB) plus a bundle
loaded on demand; the two 1.5 MB / 750 KB device PNGs are recompressed to webp.
The page is split into 18 components with all copy moved to src/data/content.ts.

Leads reuse the fitness amoCRM integration: contact lookup by phone in every
Russian spelling, deal in the first stage of pipeline 10980758, account fields
matched automatically with the rest written to a note. Rate limit, honeypot and
a localStorage fallback so a lead survives the CRM being down. Endpoint is
/api/leads/medical-centers-existing-physio; Vite runs on 5173 and the API on
3000.

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

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+67
View File
@@ -0,0 +1,67 @@
/**
* 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',
'cabinet_state',
'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-existing-physio', async (req, res) => {
const send = (status: number, body: LeadResponse) => res.status(status).json(body)
const parsed = leadSchema.safeParse(req.body)
if (!parsed.success) {
const fields: Record<string, string> = {}
for (const issue of parsed.error.issues) {
const key = issue.path.join('.')
if (key && !fields[key]) fields[key] = issue.message
}
return send(400, { ok: false, error: 'Проверьте заполненные поля.', fields })
}
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[lead] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0, contactId: 0 })
}
const { allowed, retryAfterSeconds } = limiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много заявок с этого адреса. Попробуйте позже.' })
}
if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' })
}
try {
const result = await leadService.submit(payload)
console.info(
'[lead] amoCRM lead %d (contact %d, %s)',
result.leadId,
result.contactId,
result.contactCreated ? 'new contact' : 'existing contact',
)
return send(200, { ok: true, leadId: result.leadId, contactId: result.contactId })
} catch (error) {
console.error('[lead] amoCRM submission failed:', error)
console.error('[lead] payload was:', JSON.stringify(payload))
const message =
error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.'
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.'
return send(502, { ok: false, error: message })
}
})
if (serverConfig.isProduction) {
app.use(
express.static(clientDir, {
index: false,
setHeaders(res, filePath) {
// Vite fingerprints everything under /assets, so it can be cached hard.
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
},
}),
)
// SPA fallback. Written as middleware because Express 5 no longer accepts a
// bare '*' route pattern.
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
res.sendFile(path.join(clientDir, 'index.html'))
})
}
app.listen(serverConfig.port, () => {
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)',
)
})
+155
View File
@@ -0,0 +1,155 @@
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: 'Телефон',
cabinet_state: 'Состояние кабинета',
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: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
},
cabinet_state: {
codes: ['CABINET_STATE'],
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,
cabinet_state: payload.cabinet_state,
profile: payload.profile,
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на усиление кабинета физиотерапии',
...payload.utm,
}
const fields: AmoFieldEntry[] = []
const leftovers: string[] = []
for (const [key, value] of Object.entries(data)) {
if (!value) continue
const field = fieldMap.get(key)
const entry = field ? toFieldEntry(field, value) : null
if (entry) fields.push(entry)
else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
}
// Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card.
const header = [
'Заявка с лендинга «Усиление действующего кабинета физиотерапии»',
`${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title shown in the pipeline. */
export function buildLeadName(payload: LeadPayload): string {
return `Физиотерапия — ${payload.company ?? payload.name}`
}
+124
View File
@@ -0,0 +1,124 @@
import type { LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '')
/**
* Last ten digits — the part that is stable across "+7 (999) 123-45-67",
* "8 999 1234567" and "9991234567". Used to decide whether amoCRM already
* knows a phone number.
*/
const phoneKey = (value: string) => digitsOnly(value).slice(-10)
/**
* amoCRM's full-text search matches digit substrings, but a contact stored as
* "+7 999…" is not found by a query starting with "8 999…". Try every spelling
* a Russian visitor might type.
*/
function phoneQueries(phone: string): string[] {
const digits = digitsOnly(phone)
const queries = new Set<string>([phone.trim(), digits])
if (digits.length >= 10) {
const national = digits.slice(-10)
queries.add(national)
queries.add(`7${national}`)
queries.add(`8${national}`)
}
return [...queries].filter(Boolean)
}
/** Reads the values already stored in a contact's standard multitext field. */
function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] {
const entry = contact.custom_fields_values?.find((f) => f.field_code === code)
return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean)
}
function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] {
const entries: AmoFieldEntry[] = [
{ field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] },
]
if (payload.email) {
entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] })
}
return entries
}
export interface CreatedLead {
leadId: number
contactId: number
contactCreated: boolean
}
export class LeadService {
private fieldMap?: Promise<LeadFieldMap>
constructor(private readonly amo: AmoClient) {}
private getFieldMap(): Promise<LeadFieldMap> {
this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
return this.fieldMap
}
async submit(payload: LeadPayload): Promise<CreatedLead> {
const fieldMap = await this.getFieldMap()
// 1. Reuse the existing contact when the phone or email is already known.
const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? ''])
let contactId: number
let contactCreated = false
if (existing) {
contactId = existing.id
// Add whichever channel amoCRM does not have on file yet.
const missing: AmoFieldEntry[] = []
const knownPhones = existingValues(existing, 'PHONE').map(phoneKey)
if (!knownPhones.includes(phoneKey(payload.phone))) {
missing.push({
field_code: 'PHONE',
values: [
...existingValues(existing, 'PHONE').map((value) => ({ value })),
{ value: payload.phone, enum_code: 'WORK' },
],
})
}
if (payload.email) {
const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase())
if (!knownEmails.includes(payload.email.toLowerCase())) {
missing.push({
field_code: 'EMAIL',
values: [
...existingValues(existing, 'EMAIL').map((value) => ({ value })),
{ value: payload.email, enum_code: 'WORK' },
],
})
}
}
await this.amo.appendContactFields(contactId, missing)
} else {
const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) })
contactId = created.id
contactCreated = true
}
// 2. Lead in the configured pipeline, first stage.
const { fields, note } = mapLead(payload, fieldMap)
const lead = await this.amo.createLead({
name: buildLeadName(payload),
contactId,
fields,
tags: ['заявка с сайта'],
})
// 3. Everything the account has no field for lands in a readable note.
// A failed note must not fail the lead — the lead is the valuable part.
if (note) {
await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => {
console.warn('[amo] lead %d created but the note failed:', lead.id, error)
})
}
return { leadId: lead.id, contactId, contactCreated }
}
}
+33
View File
@@ -0,0 +1,33 @@
interface Bucket {
count: number
resetAt: number
}
/**
* Minimal fixed-window limiter for the public lead endpoint. In-memory on
* purpose: a single landing page behind one Node process does not warrant a
* shared store, and losing the counters on restart is harmless.
*/
export function createRateLimiter(options: { limit: number; windowMs: number }) {
const buckets = new Map<string, Bucket>()
return function check(key: string): { allowed: boolean; retryAfterSeconds: number } {
const now = Date.now()
if (buckets.size > 5000) {
for (const [k, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(k)
}
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + options.windowMs })
return { allowed: true, retryAfterSeconds: 0 }
}
bucket.count += 1
return {
allowed: bucket.count <= options.limit,
retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000),
}
}
}
+45
View File
@@ -0,0 +1,45 @@
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. */
cabinet_state: 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> }
+37
View File
@@ -0,0 +1,37 @@
import { EquipmentSection } from './components/EquipmentSection'
import { Hero } from './components/Hero'
import { LaunchSection } from './components/LaunchSection'
import { LossEconomySection } from './components/LossEconomySection'
import { MarketSection } from './components/MarketSection'
import { ProgramsSection } from './components/ProgramsSection'
import { ProjectSection } from './components/ProjectSection'
import { RequestSection } from './components/RequestSection'
import { ResidencySection } from './components/ResidencySection'
import { ScenariosSection } from './components/ScenariosSection'
import { SiteFooter } from './components/SiteFooter'
import { SiteHeader } from './components/SiteHeader'
import { WhyExoSection } from './components/WhyExoSection'
export default function App() {
return (
<>
<SiteHeader />
<main>
<Hero />
<MarketSection />
<LossEconomySection />
<ScenariosSection />
<ProjectSection />
<EquipmentSection />
<ProgramsSection />
<LaunchSection />
<WhyExoSection />
<RequestSection />
</main>
<ResidencySection />
<SiteFooter />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

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