Convert fitness landing to React + Tailwind v4 with amoCRM lead capture
Port the single-file landing (2.6 MB of inlined CSS, JS and base64 images, kept as fitnes/legacy/index.html) to Vite + React 19 + TypeScript, with an Express API that files every form submission into amoCRM pipeline 10980758. - Extract the 14 embedded images to src/assets/images and public/ - Rebuild the design system as Tailwind v4 @theme tokens; the stock palette and breakpoints are cleared so only the EXO scale is reachable from utilities - Split the page into 15 components; all copy moves to src/data - Lead endpoint: find-or-create the contact (Russian phone spellings compared on the last 10 digits), create the lead in the pipeline's first stage, map the fields the account already has and put the rest in a note. If amoCRM is unreachable the payload is logged and kept in localStorage rather than lost. - Add a callback modal as a second entry point, tagged separately in the pipeline - Self-host Inter Variable so the layout's 760/850/900 weights render as real weights instead of snapping to bold Fidelity was checked by comparing section offsets and heights against the original at 375/480/640/900/1120/1440 px; every section and the total page height matched exactly. Loading Inter deliberately changes text metrics, so the byte-exact comparison holds against the pre-font build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "fitnes-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:client"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public (baked into the client bundle at build time — never put secrets here)
|
||||
# ---------------------------------------------------------------------------
|
||||
VITE_SITE_URL=https://exodevices.ru/fitness
|
||||
|
||||
# Sub-path the site is served from, if any. Leave as "/" when it sits at the
|
||||
# domain root; set to "/fitness/" if it is mounted under exodevices.ru/fitness.
|
||||
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=fitness-landing
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,151 @@
|
||||
# EXO Recovery Zone — лендинг для фитнес-клубов
|
||||
|
||||
React-приложение лендинга «EXO Recovery Zone». Каждая форма создаёт сделку в
|
||||
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. Название любое (например «Сайт fitness»), 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` | нет | Подпуть размещения, например `/fitness/` (по умолчанию `/`) |
|
||||
|
||||
---
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
index.html точка входа Vite (meta, Open Graph, JSON-LD)
|
||||
src/
|
||||
components/ секции лендинга и UI-примитивы
|
||||
data/ весь текстовый контент и данные конструктора
|
||||
hooks/ useConstructor, useReveal, useScrollState, …
|
||||
lib/ отправка заявки, форматирование чисел
|
||||
assets/images/ 14 изображений, извлечённых из исходного 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` 640, `md` 900, `lg` 1120, плюс
|
||||
`max-width`-варианты `narrow` (760), `phone` (520), `compact` (680), `tiny` (480).
|
||||
|
||||
Вёрстка совпадает с `legacy/index.html` попиксельно: высоты всех секций и общая
|
||||
высота страницы идентичны на 375 / 480 / 640 / 900 / 1120 / 1440 px.
|
||||
|
||||
### Шрифты
|
||||
|
||||
Inter подключён локально пакетом `@fontsource-variable/inter` — внешних запросов
|
||||
(Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном
|
||||
100–900, поэтому нестандартные веса из макета (760, 850, 900) отрисовываются
|
||||
по-настоящему, а не округляются до `bold`.
|
||||
|
||||
Подсеты разделены по `unicode-range`, браузер скачивает только нужные:
|
||||
|
||||
| подсет | размер | зачем |
|
||||
|---|---|---|
|
||||
| latin | 48 КБ | латиница |
|
||||
| cyrillic | 19 КБ | кириллица |
|
||||
| latin-ext | 85 КБ | **только ради знака `₽`** |
|
||||
|
||||
Итого ~152 КБ. Если 85 КБ ради одного символа кажутся лишними — уберите
|
||||
`latin-ext`, и `₽` будет отрисовываться системным шрифтом.
|
||||
|
||||
Символы `→ ↗ ↘ ≈` не входят ни в один подсет Inter у Google Fonts / Fontsource,
|
||||
поэтому для них всегда используется системный фолбэк. Это заметно только при
|
||||
очень крупном кегле.
|
||||
|
||||
`Manrope` и `Segoe UI` остались в стеке `--font-sans` как фолбэк, но отдельно не
|
||||
загружаются: Manrope недостижим, пока Inter грузится успешно, а Segoe UI —
|
||||
проприетарный шрифт Microsoft, который нельзя раздавать с сайта.
|
||||
|
||||
### Калькулятор
|
||||
|
||||
`оплаченные программы в день × средний чек × рабочие дни = выручка в месяц`.
|
||||
|
||||
---
|
||||
|
||||
## Известные особенности
|
||||
|
||||
* `og:image` отдаётся по абсолютному адресу `VITE_SITE_URL` + `/og-image.webp`;
|
||||
при смене домена обновите `VITE_SITE_URL` перед сборкой.
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>EXO Recovery Zone для фитнес-клубов | Экзо Групп</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Зона восстановления для фитнес-клуба: конструктор из 3 или 5 аппаратов, визуальная концепция, программы, обучение и готовая экосистема внедрения."
|
||||
/>
|
||||
<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="EXO Recovery Zone — восстановление, которое удерживает клиента в клубе" />
|
||||
<meta property="og:description" content="3 или 5 аппаратов, дизайн зоны, программы, обучение и." />
|
||||
<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="EXO Recovery Zone для фитнес-клуба" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"name": "EXO Recovery Zone для фитнес-клубов",
|
||||
"provider": { "@type": "Organization", "name": "Экзо Групп" },
|
||||
"serviceType": "Проектирование и запуск зоны восстановления в фитнес-клубе",
|
||||
"areaServed": "RU",
|
||||
"url": "https://exodevices.ru/fitness"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "exo-fitness-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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><rect width='128' height='128' rx='27' fill='#0A2540'/><path d='M30 29h38c22 0 37 14 37 35S90 99 68 99H30V29Zm19 18v34h18c11 0 18-7 18-17s-7-17-18-17H49Z' fill='white'/><path d='M45 58h27v12H45z' fill='#00C4B4'/></svg>
|
||||
|
After Width: | Height: | Size: 280 B |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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',
|
||||
'city',
|
||||
'club_format',
|
||||
'configuration',
|
||||
'comment',
|
||||
'page',
|
||||
'referrer',
|
||||
'form',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
]
|
||||
for (const key of keys) {
|
||||
const field = map.get(key)
|
||||
console.info(
|
||||
field
|
||||
? ` ✓ ${key.padEnd(14)} → «${field.name}» (id ${field.id}, ${field.type})`
|
||||
: ` · ${key.padEnd(14)} → note on the lead`,
|
||||
)
|
||||
}
|
||||
|
||||
const contactFields = await amo.getContactFields()
|
||||
const hasPhone = contactFields.some((f) => f.code === 'PHONE')
|
||||
const hasEmail = contactFields.some((f) => f.code === 'EMAIL')
|
||||
console.info(`\nContact fields: PHONE ${hasPhone ? '✓' : '✗ missing'}, EMAIL ${hasEmail ? '✓' : '✗ missing'}`)
|
||||
console.info('\nAll good — the form is ready to create leads.')
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { AmoConfig } from './config.ts'
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* amoCRM REST types (only the parts this integration touches) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface AmoEnum {
|
||||
id: number
|
||||
value: string
|
||||
enum_code?: string | null
|
||||
}
|
||||
|
||||
export interface AmoCustomField {
|
||||
id: number
|
||||
name: string
|
||||
code: string | null
|
||||
type: string
|
||||
enums?: AmoEnum[] | null
|
||||
}
|
||||
|
||||
interface AmoFieldValue {
|
||||
value: string | number | boolean | null
|
||||
enum_id?: number
|
||||
enum_code?: string
|
||||
}
|
||||
|
||||
export interface AmoFieldEntry {
|
||||
field_id?: number
|
||||
field_code?: string
|
||||
values: AmoFieldValue[]
|
||||
}
|
||||
|
||||
export interface AmoContact {
|
||||
id: number
|
||||
name?: string
|
||||
custom_fields_values?: AmoFieldEntry[] | null
|
||||
}
|
||||
|
||||
export interface AmoLead {
|
||||
id: number
|
||||
}
|
||||
|
||||
export class AmoError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly detail?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AmoError'
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Client */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const RETRYABLE = new Set([429, 500, 502, 503, 504])
|
||||
|
||||
export class AmoClient {
|
||||
private leadFields?: Promise<AmoCustomField[]>
|
||||
private contactFields?: Promise<AmoCustomField[]>
|
||||
|
||||
constructor(private readonly config: AmoConfig) {}
|
||||
|
||||
get baseUrl(): string {
|
||||
return this.config.baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses
|
||||
* for "found nothing" on every collection endpoint.
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
|
||||
attempt = 1,
|
||||
): Promise<T | null> {
|
||||
const url = new URL(path, this.config.baseUrl)
|
||||
for (const [key, value] of Object.entries(init.query ?? {})) {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value))
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
} catch (cause) {
|
||||
if (attempt < 3) {
|
||||
await sleep(attempt * 400)
|
||||
return this.request<T>(path, init, attempt + 1)
|
||||
}
|
||||
throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) return null
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
if (RETRYABLE.has(response.status) && attempt < 3) {
|
||||
await sleep(attempt * 700)
|
||||
return this.request<T>(path, init, attempt + 1)
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail)
|
||||
}
|
||||
throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Field metadata — fetched once per process, then reused */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
private async fetchFields(entity: 'leads' | 'contacts'): Promise<AmoCustomField[]> {
|
||||
const collected: AmoCustomField[] = []
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>(
|
||||
`/api/v4/${entity}/custom_fields`,
|
||||
{ query: { page, limit: 250 } },
|
||||
)
|
||||
const batch = res?._embedded?.custom_fields ?? []
|
||||
collected.push(...batch)
|
||||
if (batch.length < 250) break
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
getLeadFields(): Promise<AmoCustomField[]> {
|
||||
this.leadFields ??= this.fetchFields('leads')
|
||||
return this.leadFields
|
||||
}
|
||||
|
||||
getContactFields(): Promise<AmoCustomField[]> {
|
||||
this.contactFields ??= this.fetchFields('contacts')
|
||||
return this.contactFields
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Contacts */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/** Full-text search. amoCRM matches phones and emails regardless of format. */
|
||||
async findContact(queries: string[]): Promise<AmoContact | null> {
|
||||
for (const query of queries) {
|
||||
if (!query) continue
|
||||
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
|
||||
query: { query, limit: 1, with: 'leads' },
|
||||
})
|
||||
const contact = res?._embedded?.contacts?.[0]
|
||||
if (contact) return contact
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise<AmoContact> {
|
||||
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
|
||||
method: 'POST',
|
||||
body: [
|
||||
{
|
||||
name: input.name,
|
||||
responsible_user_id: this.config.responsibleUserId,
|
||||
custom_fields_values: input.fields.length ? input.fields : undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
const contact = res?._embedded?.contacts?.[0]
|
||||
if (!contact) throw new AmoError('amoCRM did not return the created contact')
|
||||
return contact
|
||||
}
|
||||
|
||||
/** Adds phone/email to an existing contact without dropping what is there. */
|
||||
async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise<void> {
|
||||
if (!fields.length) return
|
||||
await this.request(`/api/v4/contacts/${contactId}`, {
|
||||
method: 'PATCH',
|
||||
body: { custom_fields_values: fields },
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Leads */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates the lead in the configured pipeline. Omitting `status_id` makes
|
||||
* amoCRM drop it into that pipeline's first stage, which is what we want.
|
||||
*/
|
||||
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
|
||||
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] }
|
||||
const tags = [...new Set([...this.config.tags, ...input.tags])]
|
||||
if (tags.length) embedded.tags = tags.map((name) => ({ name }))
|
||||
|
||||
const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', {
|
||||
method: 'POST',
|
||||
body: [
|
||||
{
|
||||
name: input.name,
|
||||
pipeline_id: this.config.pipelineId,
|
||||
responsible_user_id: this.config.responsibleUserId,
|
||||
custom_fields_values: input.fields.length ? input.fields : undefined,
|
||||
_embedded: embedded,
|
||||
},
|
||||
],
|
||||
})
|
||||
const lead = res?._embedded?.leads?.[0]
|
||||
if (!lead) throw new AmoError('amoCRM did not return the created lead')
|
||||
return lead
|
||||
}
|
||||
|
||||
async addLeadNote(leadId: number, text: string): Promise<void> {
|
||||
await this.request(`/api/v4/leads/${leadId}/notes`, {
|
||||
method: 'POST',
|
||||
body: [{ note_type: 'common', params: { text } }],
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Diagnostics */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> {
|
||||
return this.request('/api/v4/account')
|
||||
}
|
||||
|
||||
async getPipeline(id: number): Promise<{
|
||||
id: number
|
||||
name: string
|
||||
_embedded?: { statuses?: { id: number; name: string; sort: number }[] }
|
||||
} | null> {
|
||||
return this.request(`/api/v4/leads/pipelines/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,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',
|
||||
}
|
||||
@@ -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/fitness-centers', 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)',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
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: 'Телефон',
|
||||
city: 'Город',
|
||||
club_format: 'Формат',
|
||||
comment: 'Комментарий',
|
||||
configuration: 'Конфигурация из конструктора',
|
||||
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: ['компания', 'организация', 'название клуба', 'клуб', 'сеть'] },
|
||||
city: { codes: ['CITY'], names: ['город'] },
|
||||
club_format: { codes: ['CLUB_FORMAT', 'FORMAT'], names: ['формат клуба', 'формат', 'тип клуба'] },
|
||||
configuration: {
|
||||
codes: ['CONFIGURATION', 'EQUIPMENT'],
|
||||
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,
|
||||
city: payload.city,
|
||||
club_format: payload.club_format,
|
||||
configuration: payload.configuration,
|
||||
comment: payload.comment,
|
||||
page: payload.page,
|
||||
referrer: payload.referrer,
|
||||
form: payload.form === 'callback' ? 'Быстрый обратный звонок' : 'Заявка на конфигурацию',
|
||||
...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 = [
|
||||
`Заявка с лендинга «EXO Recovery Zone для фитнес-клубов»`,
|
||||
`${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 {
|
||||
const who = payload.company ?? payload.name
|
||||
return payload.form === 'callback' ? `Обратный звонок — ${who}` : `Recovery Zone — ${who}`
|
||||
}
|
||||
@@ -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: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'],
|
||||
})
|
||||
|
||||
// 3. Everything the account has no field for lands in a readable note.
|
||||
// A failed note must not fail the lead — the lead is the valuable part.
|
||||
if (note) {
|
||||
await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => {
|
||||
console.warn('[amo] lead %d created but the note failed:', lead.id, error)
|
||||
})
|
||||
}
|
||||
|
||||
return { leadId: lead.id, contactId, contactCreated }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
interface Bucket {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fixed-window limiter for the public lead endpoint. In-memory on
|
||||
* purpose: a single landing page behind one Node process does not warrant a
|
||||
* shared store, and losing the counters on restart is harmless.
|
||||
*/
|
||||
export function createRateLimiter(options: { limit: number; windowMs: number }) {
|
||||
const buckets = new Map<string, Bucket>()
|
||||
|
||||
return function check(key: string): { allowed: boolean; retryAfterSeconds: number } {
|
||||
const now = Date.now()
|
||||
|
||||
if (buckets.size > 5000) {
|
||||
for (const [k, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(k)
|
||||
}
|
||||
|
||||
const bucket = buckets.get(key)
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + options.windowMs })
|
||||
return { allowed: true, retryAfterSeconds: 0 }
|
||||
}
|
||||
|
||||
bucket.count += 1
|
||||
return {
|
||||
allowed: bucket.count <= options.limit,
|
||||
retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
|
||||
export const leadFormIds = ['request', 'callback'] 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),
|
||||
city: optionalText(120),
|
||||
club_format: optionalText(120),
|
||||
comment: optionalText(4000),
|
||||
configuration: optionalText(600),
|
||||
page: optionalText(300),
|
||||
referrer: optionalText(500),
|
||||
// partialRecord, not record: z.record over an enum requires every key.
|
||||
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
|
||||
/** Honeypot. Bots fill it in; humans never see it. */
|
||||
website: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
export type LeadPayload = z.infer<typeof leadSchema>
|
||||
/** What the client sends — before zod's optional/empty-string normalisation. */
|
||||
export type LeadInput = z.input<typeof leadSchema>
|
||||
|
||||
export type LeadResponse =
|
||||
| { ok: true; leadId: number; contactId: number }
|
||||
| { ok: false; error: string; fields?: Record<string, string> }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { BenefitsSection } from './components/BenefitsSection'
|
||||
import { CallbackModal } from './components/CallbackModal'
|
||||
import { ConstructorSection } from './components/ConstructorSection'
|
||||
import { EconomicsSection } from './components/EconomicsSection'
|
||||
import { GallerySection, type GalleryImage } from './components/GallerySection'
|
||||
import { Hero } from './components/Hero'
|
||||
import { ImageModal } from './components/ImageModal'
|
||||
import { MarketSection } from './components/MarketSection'
|
||||
import { MobileCta } from './components/MobileCta'
|
||||
import { ProgramsSection } from './components/ProgramsSection'
|
||||
import { RequestSection } from './components/RequestSection'
|
||||
import { SiteFooter } from './components/SiteFooter'
|
||||
import { SiteHeader } from './components/SiteHeader'
|
||||
import { WhyExoSection } from './components/WhyExoSection'
|
||||
import { useConstructor } from './hooks/useConstructor'
|
||||
import { useScrollState } from './hooks/useScrollState'
|
||||
|
||||
export default function App() {
|
||||
const { scrolled, showMobileCta } = useScrollState()
|
||||
const constructor = useConstructor()
|
||||
|
||||
const [lightbox, setLightbox] = useState<GalleryImage | null>(null)
|
||||
const [callbackOpen, setCallbackOpen] = useState(false)
|
||||
const [commentPrefill, setCommentPrefill] = useState('')
|
||||
|
||||
const openCallback = useCallback(() => setCallbackOpen(true), [])
|
||||
const closeCallback = useCallback(() => setCallbackOpen(false), [])
|
||||
const closeLightbox = useCallback(() => setLightbox(null), [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader scrolled={scrolled} onCallbackClick={openCallback} />
|
||||
|
||||
<main>
|
||||
<Hero />
|
||||
<MarketSection />
|
||||
<EconomicsSection />
|
||||
<BenefitsSection />
|
||||
<ConstructorSection
|
||||
state={constructor}
|
||||
onRequestConfiguration={() => setCommentPrefill(constructor.requestComment)}
|
||||
/>
|
||||
<ProgramsSection />
|
||||
<GallerySection onOpen={setLightbox} />
|
||||
<WhyExoSection />
|
||||
<RequestSection configuration={constructor.configurationValue} commentPrefill={commentPrefill} />
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
<MobileCta show={showMobileCta} onCallbackClick={openCallback} />
|
||||
<ImageModal image={lightbox} onClose={closeLightbox} />
|
||||
<CallbackModal open={callbackOpen} onClose={closeCallback} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,63 @@
|
||||
import { benefits, glossary } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Section, SectionHead } from './Layout'
|
||||
|
||||
export function BenefitsSection() {
|
||||
const terms = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="benefits">
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Бизнес-эффект"
|
||||
title="Следующий рост клуба — не ещё один тренажёр. Это восстановление"
|
||||
lead="Зона встраивается в существующий клиентский поток и превращает паузу после нагрузки в понятный продукт клуба."
|
||||
/>
|
||||
|
||||
<div className="grid gap-[14px] sm:grid-cols-[repeat(2,1fr)] md:grid-cols-[repeat(4,1fr)]">
|
||||
{benefits.map((benefit) => (
|
||||
<BenefitCard key={benefit.index} {...benefit} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={terms.ref}
|
||||
className={cx(
|
||||
'mt-[18px] grid gap-[10px] rounded-[22px] border border-navy/[0.12] bg-white px-[20px] py-[18px] shadow-[0_12px_34px_rgb(4_28_46/0.04)]',
|
||||
terms.revealClass,
|
||||
)}
|
||||
>
|
||||
{glossary.map((item) => (
|
||||
<div
|
||||
key={item.term}
|
||||
className="grid grid-cols-[72px_1fr] items-start gap-[12px] text-[13px] text-muted narrow:grid-cols-[62px_1fr]"
|
||||
>
|
||||
<b className="text-[12px] tracking-[0.08em] text-navy">{item.term}</b>
|
||||
<span>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function BenefitCard({ index, title, text }: (typeof benefits)[number]) {
|
||||
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'rounded-[24px] border border-navy/[0.12] bg-white p-[24px] shadow-[0_12px_34px_rgb(4_28_46/0.05)]',
|
||||
'transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-[5px] hover:border-teal/[0.42] hover:shadow-soft',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<div className="mb-[26px] text-[12px] font-black tracking-[0.1em] text-teal">{index}</div>
|
||||
<h3 className="mb-[10px] text-[23px]">{title}</h3>
|
||||
<p className="text-[14px] text-muted">{text}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { cx } from '../lib/cx'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'ghost' | 'outline'
|
||||
|
||||
const base =
|
||||
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-extrabold ' +
|
||||
'transition-[transform,box-shadow,background-color,color,border-color] duration-250 hover:-translate-y-[2px] ' +
|
||||
'[&>svg]:size-[19px] [&>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-[42px]` passed next to the default
|
||||
* `min-h-[54px]` would silently lose. Replacing the slot means the losing
|
||||
* class is never emitted in the first place.
|
||||
*/
|
||||
const defaultSize = 'min-h-[54px] px-[22px]'
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-teal text-navy-deep shadow-[0_12px_34px_rgb(0_196_180/0.25)] hover:bg-teal-bright hover:shadow-[0_16px_40px_rgb(0_196_180/0.34)]',
|
||||
ghost:
|
||||
'border-white/[0.26] bg-white/[0.06] text-white backdrop-blur-[12px] hover:border-white/[0.55] hover:bg-white/[0.11]',
|
||||
outline: 'border-navy/20 bg-white text-navy hover:border-teal',
|
||||
}
|
||||
|
||||
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
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
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,147 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { contacts } from '../data/content'
|
||||
import { useBodyLock } from '../hooks/useBodyLock'
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||
import { submitLead } from '../lib/lead'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Button } from './Button'
|
||||
import { ConsentCheckbox, Honeypot, TextField } from './FormField'
|
||||
import { ArrowRightIcon } from './Icons'
|
||||
|
||||
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
|
||||
|
||||
/**
|
||||
* Compact second entry point: name + phone only. Leads land in the same
|
||||
* amoCRM pipeline, tagged so they can be told apart from the full request.
|
||||
*/
|
||||
export function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const firstFieldRef = useRef<HTMLInputElement>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [consent, setConsent] = useState(false)
|
||||
const [honeypot, setHoneypot] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
|
||||
|
||||
const close = useCallback(() => onClose(), [onClose])
|
||||
useBodyLock(open)
|
||||
useEscapeKey(open, close)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) firstFieldRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = formRef.current
|
||||
if (!form) return
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity()
|
||||
setStatus({ text: 'Проверьте обязательные поля и согласие.', tone: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
if (honeypot) return
|
||||
|
||||
setPending(true)
|
||||
setStatus({ text: '', tone: 'idle' })
|
||||
|
||||
const result = await submitLead('callback', { name, phone, website: honeypot })
|
||||
|
||||
if (result.ok) {
|
||||
setName('')
|
||||
setPhone('')
|
||||
setConsent(false)
|
||||
setStatus({ text: 'Спасибо. Мы перезвоним в ближайшее рабочее время.', tone: 'success' })
|
||||
} else {
|
||||
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
|
||||
}
|
||||
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="callbackTitle"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) close()
|
||||
}}
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-[#020D16]/[0.92] p-[18px] backdrop-blur-[10px]"
|
||||
>
|
||||
<div className="relative w-full max-w-[420px] rounded-[28px] bg-white p-[clamp(24px,4vw,34px)] text-ink shadow-deep">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
onClick={close}
|
||||
className="absolute top-[14px] right-[14px] size-[38px] cursor-pointer rounded-full border border-navy/15 text-[22px] text-muted hover:border-teal hover:text-navy"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h3 id="callbackTitle" className="mb-[8px] text-[26px]">
|
||||
Перезвоним вам
|
||||
</h3>
|
||||
<p className="mb-[20px] text-[13px] text-muted">
|
||||
Оставьте имя и телефон — специалист свяжется и ответит на вопросы по зоне восстановления. Или позвоните сами:{' '}
|
||||
<a className="font-extrabold text-navy underline underline-offset-[3px]" href={contacts.phoneHref}>
|
||||
{contacts.phone}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<form ref={formRef} noValidate onSubmit={onSubmit}>
|
||||
<div className="grid gap-[12px]">
|
||||
<TextField
|
||||
ref={firstFieldRef}
|
||||
id="callbackName"
|
||||
label="Имя *"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
placeholder="Ваше имя"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
id="callbackPhone"
|
||||
label="Телефон *"
|
||||
name="phone"
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
placeholder="+7 999 000-00-00"
|
||||
required
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Honeypot value={honeypot} onChange={setHoneypot} />
|
||||
<ConsentCheckbox checked={consent} onChange={setConsent} />
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Отправляем…' : 'Жду звонка'}
|
||||
{pending ? null : <ArrowRightIcon />}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cx(
|
||||
'mt-[11px] min-h-[20px] text-[12px]',
|
||||
status.tone === 'success' && 'text-[#087F73]',
|
||||
status.tone === 'error' && 'text-[#A14D12]',
|
||||
status.tone === 'idle' && 'text-muted',
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { configImages, deviceIds, devices, presetIds, presets } from '../data/devices'
|
||||
import type { ConstructorState } from '../hooks/useConstructor'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Button, ButtonLink } from './Button'
|
||||
import { ArrowRightIcon, CheckIcon } from './Icons'
|
||||
import { Container, Label, Section, SectionHead } from './Layout'
|
||||
|
||||
/** The original used two different word forms for the two modes. */
|
||||
const MODES = [
|
||||
{ value: 3, label: '3 аппарата' },
|
||||
{ value: 5, label: '5 аппаратов' },
|
||||
] as const
|
||||
|
||||
export function ConstructorSection({
|
||||
state,
|
||||
onRequestConfiguration,
|
||||
}: {
|
||||
state: ConstructorState
|
||||
onRequestConfiguration: () => void
|
||||
}) {
|
||||
const shell = useReveal<HTMLDivElement>()
|
||||
const image = useFadingImage(state.mode === 5 ? configImages.five : configImages.three)
|
||||
|
||||
return (
|
||||
<Section id="constructor" tone="dark">
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Конструктор EXO"
|
||||
title="Не один комплект для всех. Собираем зону под ваш клуб"
|
||||
lead="Выберите компактное ядро из трёх аппаратов или полную платформу из пяти. Финальная конфигурация определяется после аудита площади, потока, аудитории, формата тренировок."
|
||||
tone="dark"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={shell.ref}
|
||||
className={cx(
|
||||
'overflow-hidden rounded-[38px] border border-white/[0.08] bg-[linear-gradient(145deg,#0A2540,#061B2F)] shadow-deep',
|
||||
shell.revealClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-[24px] border-b border-white/10 p-[clamp(24px,5vw,46px)] md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<Label>Интерактивный подбор</Label>
|
||||
<p className="mt-[13px] text-[14px] text-mist">
|
||||
Нажмите на аппараты — справа изменится состав и логика зоны.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div role="group" aria-label="Количество аппаратов" className="grid max-w-[430px] grid-cols-[1fr_1fr] rounded-full bg-white/[0.08] p-[5px]">
|
||||
{MODES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => state.setMode(value)}
|
||||
className={cx(
|
||||
'min-h-[46px] cursor-pointer rounded-full border-0 font-black transition duration-250',
|
||||
state.mode === value
|
||||
? 'bg-teal text-navy-deep shadow-[0_8px_25px_rgb(0_196_180/0.22)]'
|
||||
: 'bg-transparent text-mist',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-[0.92fr_1.08fr] lg:grid-cols-[1fr_1.18fr]">
|
||||
<div className="border-b border-white/10 p-[clamp(22px,4vw,40px)] md:border-r md:border-b-0">
|
||||
<div className="mb-[21px] flex items-end justify-between gap-[14px]">
|
||||
<div>
|
||||
<h3 className="mb-[4px] text-[clamp(25px,3vw,36px)] text-white">Состав зоны</h3>
|
||||
<p className="text-[13px] text-mist">{state.hint}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/15 px-[11px] py-[8px] text-[12px] whitespace-nowrap text-[#BFD3DE]">
|
||||
<strong className="text-teal">{state.selected.length}</strong> / {state.mode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-[10px] sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{deviceIds.map((id) => {
|
||||
const device = devices[id]
|
||||
const active = state.selected.includes(id)
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => state.toggleDevice(id)}
|
||||
className={cx(
|
||||
'relative min-h-[170px] overflow-hidden rounded-[20px] border p-[13px] text-left text-white',
|
||||
'transition-[transform,border-color,background-color] duration-250 hover:-translate-y-[3px] hover:border-teal/[0.45]',
|
||||
'sm:min-h-[185px] phone:min-h-[162px]',
|
||||
state.mode === 5 ? 'cursor-default' : 'cursor-pointer',
|
||||
active
|
||||
? 'border-teal bg-teal/[0.13] shadow-[inset_0_0_0_1px_rgb(0_196_180/0.2)]'
|
||||
: 'border-white/[0.11] bg-white/[0.055]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'absolute top-[11px] right-[11px] z-2 grid size-[25px] place-items-center rounded-full border [&>svg]:size-[15px]',
|
||||
active
|
||||
? 'border-teal bg-teal text-navy'
|
||||
: 'border-white/30 bg-navy-deep/[0.55] text-transparent',
|
||||
)}
|
||||
>
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<img
|
||||
alt={`Аппарат ${device.name}`}
|
||||
src={device.image}
|
||||
className="mb-[5px] h-[84px] w-full object-contain drop-shadow-[0_12px_20px_rgb(0_0_0/0.24)] phone:h-[76px]"
|
||||
/>
|
||||
<strong className="block text-[14px] leading-[1.2]">{device.name}</strong>
|
||||
<small className="mt-[4px] block text-[10px] leading-[1.3] text-[#9DB4C1]">{device.caption}</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.mode === 3 ? (
|
||||
<div
|
||||
aria-label="Готовые варианты из трёх аппаратов"
|
||||
className="no-scrollbar flex gap-[8px] overflow-auto pt-[18px]"
|
||||
>
|
||||
{presetIds.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => state.applyPreset(id)}
|
||||
className={cx(
|
||||
'flex-none cursor-pointer rounded-full border bg-transparent px-[12px] py-[9px] text-[11px] font-extrabold hover:border-teal hover:text-teal',
|
||||
state.preset === id ? 'border-teal text-teal' : 'border-white/15 text-[#BFD1DB]',
|
||||
)}
|
||||
>
|
||||
{presets[id].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white p-[clamp(22px,4vw,40px)]">
|
||||
<div className="group relative mb-[24px] h-[clamp(230px,35vw,420px)] overflow-hidden rounded-[25px] bg-navy">
|
||||
<img
|
||||
alt={image.alt}
|
||||
src={image.src}
|
||||
style={{ opacity: image.opacity }}
|
||||
className="size-full object-cover transition-[opacity,transform] duration-[350ms,700ms] group-hover:scale-[1.025]"
|
||||
/>
|
||||
<span className="absolute top-[14px] left-[14px] rounded-full bg-navy-deep/[0.84] px-[12px] py-[9px] text-[11px] font-black text-white backdrop-blur-[12px]">
|
||||
{state.tag}
|
||||
</span>
|
||||
<span className="absolute right-[14px] bottom-[14px] max-w-[230px] rounded-[14px] bg-white/[0.88] px-[12px] py-[10px] text-[10px] leading-[1.35] text-navy backdrop-blur-[12px]">
|
||||
Пример визуализации. Итоговый рендер создаётся под выбранный состав и интерьер клуба.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-[15px]">
|
||||
<div>
|
||||
<h3 className="mb-[8px] text-[clamp(27px,3.5vw,42px)]">{state.title}</h3>
|
||||
<p className="text-[14px] text-muted">{state.subtitle}</p>
|
||||
</div>
|
||||
<div className="text-[54px] leading-[0.8] font-[950] text-navy/[0.08]">{state.number}</div>
|
||||
</div>
|
||||
|
||||
<div className="my-[22px] flex flex-wrap gap-[8px]">
|
||||
{state.names.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-full bg-teal-pale px-[12px] py-[9px] text-[12px] font-extrabold text-navy"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-[24px] grid gap-[9px]">
|
||||
{state.benefits.map((benefit) => (
|
||||
<div key={benefit} className="grid grid-cols-[25px_1fr] gap-[9px] text-[13px] text-muted">
|
||||
<i className="grid size-[22px] place-items-center rounded-full bg-teal/[0.12] text-teal [&>svg]:size-[13px]">
|
||||
<CheckIcon />
|
||||
</i>
|
||||
<span>{benefit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap gap-[10px] phone:[&>*]:w-full">
|
||||
<ButtonLink
|
||||
href="#request"
|
||||
onClick={onRequestConfiguration}
|
||||
style={{
|
||||
opacity: state.isValid ? 1 : 0.45,
|
||||
pointerEvents: state.isValid ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
Получить эту конфигурацию
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<Button type="button" variant="outline" onClick={state.reset}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fades the render when the mode changes, mirroring the original's
|
||||
* "fade out, swap src, fade in" timing.
|
||||
*/
|
||||
function useFadingImage(target: { src: string; alt: string }) {
|
||||
const [shown, setShown] = useState(target)
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (target.src === shown.src) return
|
||||
setOpacity(0)
|
||||
timer.current = setTimeout(() => {
|
||||
setShown(target)
|
||||
setOpacity(1)
|
||||
}, 180)
|
||||
return () => clearTimeout(timer.current)
|
||||
}, [target, shown.src])
|
||||
|
||||
return { ...shown, opacity }
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useState } from 'react'
|
||||
import { econFormula, econGain, econLoss, econMetrics, econOps } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { formatMoney, formatNumber, parseAmount } from '../lib/format'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Section } from './Layout'
|
||||
|
||||
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
|
||||
const badge = 'grid size-[28px] place-items-center rounded-[10px] text-[11px]'
|
||||
|
||||
export function EconomicsSection() {
|
||||
const head = useReveal<HTMLElement>()
|
||||
const metrics = useReveal<HTMLDivElement>()
|
||||
const loss = useReveal<HTMLElement>()
|
||||
const gain = useReveal<HTMLElement>()
|
||||
const bridge = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="fitness-economics" className="overflow-hidden bg-econ-bg text-navy">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-[270px] -right-[260px] size-[520px] rounded-full bg-teal/[0.13] blur-[18px]"
|
||||
/>
|
||||
|
||||
<Container>
|
||||
<header ref={head.ref} className={cx('relative z-1 mb-[28px] grid max-w-[920px] gap-[14px]', head.revealClass)}>
|
||||
<span className="flex items-center gap-[10px] text-[11px] font-[850] tracking-[0.095em] text-econ-teal-ink uppercase before:h-[2px] before:w-[28px] before:rounded-[3px] before:bg-teal before:content-['']">
|
||||
Экономика действующей клиентской базы
|
||||
</span>
|
||||
<h2 className="text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy tiny:text-[34px]">
|
||||
Клуб теряет деньги не на тренировке — а между посещениями
|
||||
</h2>
|
||||
<p className="max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-[#60778B]">
|
||||
Более половины прироста рынка уже обеспечивается повышением цен, а средняя годовая удерживаемость составляет
|
||||
около 66%. Recovery Zone создаёт новую выручку без продажи ещё одной клубной карты.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={metrics.ref}
|
||||
aria-label="Ключевые показатели экономики фитнес-клуба"
|
||||
className={cx('relative z-1 mb-[14px] grid grid-cols-2 gap-[10px] sm:grid-cols-4', metrics.revealClass)}
|
||||
>
|
||||
{econMetrics.map((metric) => (
|
||||
<article
|
||||
key={metric.label}
|
||||
className="min-w-0 rounded-[18px] border border-teal/[0.26] bg-white/90 px-[16px] py-[17px] shadow-[0_12px_36px_rgb(4_31_50/0.055)] tiny:px-[13px] tiny:py-[15px]"
|
||||
>
|
||||
<strong className="block text-[clamp(22px,3vw,34px)] leading-[1.05] tracking-[-0.035em] text-econ-teal-deep tabular-nums">
|
||||
{metric.value}
|
||||
</strong>
|
||||
<span className="mt-[7px] block text-[12px] leading-[1.32] font-[760] text-navy">{metric.label}</span>
|
||||
<small className="mt-[4px] block text-[10px] leading-[1.35] text-[#60778B]">{metric.note}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-1 grid gap-[14px] md:grid-cols-2">
|
||||
<article
|
||||
ref={loss.ref}
|
||||
className={cx(
|
||||
'econ-panel-loss min-w-0 rounded-[25px] p-[22px] text-white shadow-[0_24px_60px_rgb(4_25_41/0.16)] md:p-[27px] tiny:p-[18px]',
|
||||
loss.revealClass,
|
||||
)}
|
||||
>
|
||||
<h3 className="mb-[17px] flex items-center gap-[11px] text-[20px] leading-[1.15] tracking-[-0.025em] text-white">
|
||||
<i className="grid size-[34px] place-items-center rounded-[12px] bg-econ-red/[0.16] text-[17px] not-italic text-econ-red-pale">
|
||||
↘
|
||||
</i>
|
||||
Где клуб недополучает выручку
|
||||
</h3>
|
||||
<div className="grid gap-[9px]">
|
||||
{econLoss.map((item) => (
|
||||
<div key={item.num} className={cx(panelItem, 'border border-white/[0.11] bg-white/[0.045]')}>
|
||||
<b className={cx(badge, 'bg-econ-red/[0.16] text-econ-red-pale')}>{item.num}</b>
|
||||
<div>
|
||||
<strong className="mt-px mb-[4px] block text-[14px] leading-[1.25]">{item.title}</strong>
|
||||
<span className="block text-[11px] leading-[1.43] text-white/[0.65]">{item.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
ref={gain.ref}
|
||||
className={cx(
|
||||
'min-w-0 rounded-[25px] border border-teal/[0.34] bg-white/[0.96] p-[22px] shadow-[0_24px_60px_rgb(4_31_50/0.075)] md:p-[27px] tiny:p-[18px]',
|
||||
gain.revealClass,
|
||||
)}
|
||||
>
|
||||
<h3 className="mb-[17px] flex items-center gap-[11px] text-[20px] leading-[1.15] tracking-[-0.025em] text-navy">
|
||||
<i className="grid size-[34px] place-items-center rounded-[12px] bg-econ-ice text-[17px] not-italic text-econ-teal-ink">
|
||||
↗
|
||||
</i>
|
||||
На чём зарабатывает клуб с Экзо
|
||||
</h3>
|
||||
<div className="grid gap-[9px]">
|
||||
{econGain.map((item) => (
|
||||
<div
|
||||
key={item.num}
|
||||
className={cx(panelItem, 'border border-navy/[0.095] bg-linear-to-b from-white to-[#F7FBFB]')}
|
||||
>
|
||||
<b className={cx(badge, 'bg-econ-ice text-econ-teal-ink')}>{item.num}</b>
|
||||
<div>
|
||||
<strong className="mt-px mb-[4px] block text-[14px] leading-[1.25]">{item.title}</strong>
|
||||
<span className="block text-[11px] leading-[1.43] text-[#60778B]">{item.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={bridge.ref}
|
||||
className={cx(
|
||||
'relative z-1 my-[14px] grid gap-[10px] rounded-[19px] border border-teal/[0.28] bg-[linear-gradient(115deg,rgb(0_196_180/0.12),rgb(255_255_255/0.94))] px-[18px] py-[16px]',
|
||||
bridge.revealClass,
|
||||
)}
|
||||
>
|
||||
<strong className="text-[16px] leading-[1.25] text-navy">
|
||||
Экономика строится на текущей базе, а не на дополнительном маркетинговом трафике
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-[8px] sm:grid-cols-4 tiny:grid-cols-[1fr_1fr]">
|
||||
{econOps.map((op) => (
|
||||
<div key={op.value} className="rounded-[14px] border border-teal/[0.22] bg-white px-[12px] py-[11px]">
|
||||
<b className="block text-[17px] leading-[1.05] text-econ-teal-deep">{op.value}</b>
|
||||
<span className="mt-[4px] block text-[9px] leading-[1.25] text-[#60778B]">{op.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] leading-[1.5] text-[#60778B]">
|
||||
Итоговая конфигурация и экономика зависят от базы клуба, формата тренировок, выбранной модели работы,
|
||||
тарифов и загрузки зоны.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RevenueCalculator />
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function RevenueCalculator() {
|
||||
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||
const [programs, setPrograms] = useState('')
|
||||
const [check, setCheck] = useState('')
|
||||
const [days, setDays] = useState('30')
|
||||
|
||||
const programsValue = parseAmount(programs)
|
||||
const checkValue = parseAmount(check)
|
||||
const daysValue = parseAmount(days)
|
||||
const ready = programsValue > 0 && checkValue > 0 && daysValue > 0
|
||||
const volume = programsValue * daysValue
|
||||
const month = volume * checkValue
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'econ-calc-surface relative z-1 mt-[14px] rounded-[25px] p-[22px] text-white shadow-[0_25px_70px_rgb(4_25_41/0.18)] md:p-[28px]',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<div className="mb-[17px] grid gap-[6px]">
|
||||
<span className="text-[10px] font-[850] tracking-[0.09em] text-econ-teal-bright uppercase">Простой расчёт</span>
|
||||
<h3 className="text-[22px] leading-[1.15] text-white">Сколько зона восстановления может приносить клубу</h3>
|
||||
<p className="text-[11px] leading-[1.45] text-white/[0.58]">
|
||||
Без процентов и сложных метрик: укажите продажи в день, средний чек и количество рабочих дней.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label="Формула расчёта выручки Recovery Zone"
|
||||
className="mb-[17px] grid grid-cols-[minmax(0,1fr)_24px_minmax(0,1fr)_24px_minmax(0,1fr)_24px_minmax(0,1.12fr)] items-stretch gap-[8px] rounded-[18px] border border-teal/[0.22] bg-white/[0.055] p-[12px] compact:grid-cols-[1fr_1fr]"
|
||||
>
|
||||
{econFormula.map((step, index) => (
|
||||
<FormulaFragment key={step.badge} step={step} index={index} isResult={index === econFormula.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-[13px] md:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] md:items-end">
|
||||
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 tiny:grid-cols-[1fr]">
|
||||
<CalcField
|
||||
id="fitPrograms"
|
||||
label="Оплаченных программ восстановления в день"
|
||||
hint="Разовая сессия, пакет или короткий курс — одна продажа."
|
||||
value={programs}
|
||||
onChange={setPrograms}
|
||||
placeholder="ваше число"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
<CalcField
|
||||
id="fitCheck"
|
||||
label="Средний чек одной программы, ₽"
|
||||
hint="Фактическая средняя сумма оплаты"
|
||||
value={check}
|
||||
onChange={setCheck}
|
||||
placeholder="ваш тариф"
|
||||
min={0}
|
||||
step={100}
|
||||
/>
|
||||
<CalcField
|
||||
id="fitDays"
|
||||
label="Рабочих дней в месяц"
|
||||
hint="Сколько дней зона принимает клиентов"
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
min={1}
|
||||
max={31}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-[9px] md:self-stretch tiny:grid-cols-[1fr]">
|
||||
<CalcResult
|
||||
label="Дополнительная выручка / месяц"
|
||||
value={ready ? formatMoney(month) : '—'}
|
||||
note={
|
||||
ready
|
||||
? `${formatNumber(programsValue)} × ${formatNumber(daysValue)} = ${formatNumber(volume)} программ в месяц`
|
||||
: 'Введите программы в день и средний чек'
|
||||
}
|
||||
/>
|
||||
<CalcResult
|
||||
label="Дополнительная выручка / год"
|
||||
value={ready ? formatMoney(month * 12) : '—'}
|
||||
note="до вычета расходов"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-[13px] text-[9px] leading-[1.45] text-white/[0.43]">
|
||||
Расчёт показывает сценарную выручку, а не финансовую гарантию. Итоговая модель уточняется по конфигурации
|
||||
аппаратов, тарифам, ФОТ, загрузке и формату работы Recovery Zone.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormulaFragment({
|
||||
step,
|
||||
index,
|
||||
isResult,
|
||||
}: {
|
||||
step: (typeof econFormula)[number]
|
||||
index: number
|
||||
isResult: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{index > 0 ? (
|
||||
<b className="self-center text-center text-[20px] text-econ-teal-bright compact:hidden">
|
||||
{isResult ? '=' : '×'}
|
||||
</b>
|
||||
) : null}
|
||||
<div
|
||||
className={cx(
|
||||
'flex min-w-0 items-center gap-[9px] rounded-[13px] border p-[10px] compact:min-h-[66px]',
|
||||
isResult
|
||||
? 'border-econ-teal-bright/[0.34] bg-[linear-gradient(135deg,rgb(0_196_180/0.25),rgb(0_196_180/0.09))]'
|
||||
: 'border-white/[0.08] bg-white/[0.055]',
|
||||
)}
|
||||
>
|
||||
<em className="grid size-[27px] shrink-0 place-items-center rounded-[9px] bg-econ-teal-bright text-[12px] font-black not-italic text-navy-deep">
|
||||
{step.badge}
|
||||
</em>
|
||||
<span className={cx('text-[10px] leading-[1.3] font-[760]', isResult ? 'text-white' : 'text-white/[0.78]')}>
|
||||
{step.lines[0]}
|
||||
<br />
|
||||
{step.lines[1]}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CalcField({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-[6px]">
|
||||
<label htmlFor={id} className="text-[10px] font-[760] text-white/[0.72]">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-[48px] w-full min-w-0 rounded-[13px] border border-white/[0.17] bg-white/[0.075] px-[13px] text-[15px] font-extrabold text-white tabular-nums outline-none placeholder:text-white/[0.28] focus:border-teal focus:shadow-[0_0_0_3px_rgb(0_196_180/0.13)]"
|
||||
/>
|
||||
<small className="-mt-px block text-[8.5px] leading-[1.3] text-white/[0.42]">{hint}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalcResult({ label, value, note }: { label: string; value: string; note: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[16px] border border-teal/[0.28] bg-teal/[0.09] p-[15px] md:flex md:flex-col md:justify-center">
|
||||
<span className="block text-[9px] tracking-[0.055em] text-white/[0.56] uppercase">{label}</span>
|
||||
<strong className="mt-[6px] block text-[clamp(19px,3vw,29px)] leading-[1.05] tracking-[-0.035em] whitespace-nowrap text-econ-teal-bright tabular-nums tiny:whitespace-normal">
|
||||
{value}
|
||||
</strong>
|
||||
<small className="mt-[4px] block text-[9px] leading-[1.3] text-white/[0.48]">{note}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
||||
import { cx } from '../lib/cx'
|
||||
|
||||
export const controlClass =
|
||||
'w-full rounded-[15px] border border-navy/15 bg-field px-[15px] py-[14px] text-ink outline-none ' +
|
||||
'transition-[border-color,box-shadow,background-color] duration-200 ' +
|
||||
'focus:border-teal focus:bg-white focus:shadow-[0_0_0_4px_rgb(0_196_180/0.10)]'
|
||||
|
||||
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className={cx('flex flex-col gap-[7px]', full && 'sm:col-span-full')}>
|
||||
<label htmlFor={id} className="text-[11px] font-extrabold text-muted">
|
||||
{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, 'h-[51px]')} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
id,
|
||||
label,
|
||||
options,
|
||||
full,
|
||||
...rest
|
||||
}: { id: string; label: string; options: string[]; full?: boolean } & ComponentPropsWithRef<'select'>) {
|
||||
return (
|
||||
<Field id={id} label={label} full={full}>
|
||||
<select id={id} className={cx(controlClass, 'h-[51px]')} {...rest}>
|
||||
{options.map((option) => (
|
||||
<option key={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</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-[93px] resize-y')} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
|
||||
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<input
|
||||
aria-hidden="true"
|
||||
autoComplete="off"
|
||||
className="honeypot"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConsentCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<label className={cx('my-[13px] grid grid-cols-[18px_1fr] gap-[9px] text-[10px] text-muted', className)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/* mb-[3px] reproduces the UA checkbox margin the original page inherited. */
|
||||
className="mt-[2px] mb-[3px] accent-teal"
|
||||
/>
|
||||
<span>Согласен на обработку персональных данных и получение ответа по проекту.</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { gallery } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Section, SectionHead } from './Layout'
|
||||
|
||||
export interface GalleryImage {
|
||||
image: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function GallerySection({ onOpen }: { onOpen: (image: GalleryImage) => void }) {
|
||||
return (
|
||||
<Section id="formats" tone="navy">
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Визуальная концепция"
|
||||
title="Recovery должен выглядеть частью фитнеса, а не «медицинским углом»"
|
||||
lead="Используем готовые рендеры как отправную точку и создаём визуализацию под интерьер, площадь и выбранный конструктор вашего клуба."
|
||||
tone="dark"
|
||||
/>
|
||||
|
||||
<div className="no-scrollbar flex snap-x snap-mandatory gap-[14px] overflow-auto pb-[18px] md:grid md:grid-cols-[repeat(2,1fr)] md:overflow-visible lg:grid-cols-[repeat(4,1fr)]">
|
||||
{gallery.map((item) => (
|
||||
<GalleryCard key={item.title} item={item} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-[10px] flex items-center gap-[8px] text-[12px] text-[#AEC0CC]">
|
||||
<span className="h-px w-[31px] bg-teal" />
|
||||
Нажмите на визуализацию, чтобы открыть крупнее
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function GalleryCard({ item, onOpen }: { item: (typeof gallery)[number]; onOpen: (image: GalleryImage) => void }) {
|
||||
const { ref, revealClass } = useReveal<HTMLButtonElement>()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={() => onOpen({ image: item.image, alt: item.alt })}
|
||||
aria-label={`Открыть визуализацию: ${item.title}`}
|
||||
className={cx(
|
||||
'group relative flex-none basis-[min(88vw,520px)] cursor-zoom-in snap-start overflow-hidden rounded-[28px] bg-white text-left text-ink shadow-[0_16px_50px_rgb(0_0_0/0.18)]',
|
||||
'md:min-w-0 lg:rounded-[24px]',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<span className="absolute top-[14px] left-[14px] z-1 rounded-full bg-navy-deep/[0.82] px-[11px] py-[8px] text-[10px] font-black text-white backdrop-blur-[12px]">
|
||||
{item.badge}
|
||||
</span>
|
||||
<div className="h-[300px] overflow-hidden md:h-[350px] lg:h-[250px]">
|
||||
<img
|
||||
alt={item.alt}
|
||||
src={item.image}
|
||||
className="size-full object-cover transition-transform duration-600 group-hover:scale-[1.035]"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-[21px] pt-[20px] pb-[22px] lg:min-h-[134px]">
|
||||
<h3 className="mb-[7px] text-[21px]">{item.title}</h3>
|
||||
<p className="text-[13px] text-muted">{item.text}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import heroBg from '../assets/images/hero-bg.webp'
|
||||
import { heroFacts, heroSteps } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { ButtonLink } from './Button'
|
||||
import { ArrowRightIcon } from './Icons'
|
||||
import { Container, Label, LabelDot } from './Layout'
|
||||
|
||||
export function Hero() {
|
||||
const copy = useReveal<HTMLDivElement>()
|
||||
const card = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<section
|
||||
id="top"
|
||||
className="relative isolate flex min-h-[min(900px,100svh)] items-end overflow-hidden bg-navy-deep pt-[128px] pb-[42px] text-white md:min-h-[860px] md:pb-[62px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-30 scale-[1.025] bg-cover bg-[position:54%_center]"
|
||||
style={{ backgroundImage: `url(${heroBg})` }}
|
||||
/>
|
||||
<div aria-hidden="true" className="hero-scrim absolute inset-0 -z-20" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -right-[210px] -bottom-[220px] -z-10 size-[520px] rounded-full bg-teal/[0.42] blur-[115px]"
|
||||
/>
|
||||
|
||||
<Container className="grid items-end gap-[34px] md:grid-cols-[minmax(0,1fr)_315px]">
|
||||
<div ref={copy.ref} className={`max-w-[820px] ${copy.revealClass}`}>
|
||||
<Label>EXO Performance / Recovery Zone</Label>
|
||||
|
||||
<h1 className="my-[18px] mb-[24px] text-[clamp(46px,7.1vw,86px)] leading-[0.94] text-balance
|
||||
phone:text-[clamp(34px,11.3vw,44px)]">
|
||||
Восстановление, которое <span className="text-teal-bright">удерживает клиента</span> в клубе
|
||||
</h1>
|
||||
|
||||
<p className="mb-[30px] max-w-[700px] text-[clamp(18px,2vw,23px)] leading-[1.5] text-[#D9E7EE]">
|
||||
Готовая зона внутри фитнес-центра: клиент проходит путь «тренировка → восстановление → контроль → следующий
|
||||
визит», не уходя во внешние клиники и recovery-студии.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-[12px] phone:[&>a]:w-full">
|
||||
<ButtonLink href="#constructor">
|
||||
Собрать конфигурацию
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<ButtonLink href="#request" variant="ghost">
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<div className="mt-[34px] grid max-w-[790px] grid-cols-3 gap-[10px] phone:grid-cols-[1fr]">
|
||||
{heroFacts.map((fact) => (
|
||||
<div
|
||||
key={fact.title}
|
||||
className="rounded-[18px] border border-white/[0.17] bg-navy-deep/[0.55] p-[16px] backdrop-blur-[14px] phone:flex phone:items-baseline phone:justify-between phone:gap-[12px]"
|
||||
>
|
||||
<strong className="block text-[19px] text-white phone:text-[17px]">{fact.title}</strong>
|
||||
<span className="text-[12px] text-[#B9CBD6]">
|
||||
{fact.emphasis ? <b className="font-[850] text-white">{fact.emphasis}</b> : null}
|
||||
{fact.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
className="mt-[32px] inline-flex items-center gap-[10px] text-[12px] font-bold text-white/[0.64]"
|
||||
href="#market"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="relative h-[36px] w-[24px] rounded-[14px] border border-white/[0.35] before:absolute before:top-[8px] before:left-1/2 before:h-[7px] before:w-[3px] before:animate-scroll-dot before:rounded-[3px] before:bg-teal before:content-['']"
|
||||
/>
|
||||
Почему это актуально сейчас
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
ref={card.ref}
|
||||
aria-label="Что получает фитнес-клуб"
|
||||
className={`hidden rounded-[28px] border border-white/[0.17] bg-navy-deep/[0.64] p-[23px] shadow-[0_24px_70px_rgb(0_0_0/0.22)] backdrop-blur-[18px] md:block ${card.revealClass}`}
|
||||
>
|
||||
<div className="mb-[18px] flex items-center justify-between gap-[12px]">
|
||||
<strong className="text-[18px]">Новое направление.</strong>
|
||||
<LabelDot />
|
||||
</div>
|
||||
<div className="grid gap-[11px]">
|
||||
{heroSteps.map((step) => (
|
||||
<div key={step.num} className="grid grid-cols-[31px_1fr] items-start gap-[11px]">
|
||||
<b className="grid size-[31px] place-items-center rounded-full bg-teal/[0.14] text-[12px] text-teal">
|
||||
{step.num}
|
||||
</b>
|
||||
<span className="pt-[5px] text-[13px] text-[#CBD9E2]">{step.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</Container>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export function ArrowRightIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M5 12h14M13 6l6 6-6 6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="m5 12 4 4L19 6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.2"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PhoneIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.6 10.8c1.7 3.4 4.2 5.9 7.6 7.6l2.5-2.5c.3-.3.8-.4 1.2-.3 1.3.4 2.6.6 4 .6.7 0 1.1.4 1.1 1.1V21c0 .7-.4 1.1-1.1 1.1C11 22.1 1.9 13 1.9 1.9 1.9 1.2 2.3.8 3 .8h3.7c.7 0 1.1.4 1.1 1.1 0 1.4.2 2.7.6 4 .1.4 0 .9-.3 1.2l-2.5 2.5 1 1.2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { GalleryImage } from './GallerySection'
|
||||
import { useBodyLock } from '../hooks/useBodyLock'
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||
|
||||
export function ImageModal({ image, onClose }: { image: GalleryImage | null; onClose: () => void }) {
|
||||
const close = useCallback(() => onClose(), [onClose])
|
||||
useBodyLock(Boolean(image))
|
||||
useEscapeKey(Boolean(image), close)
|
||||
|
||||
if (!image) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Просмотр визуализации"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) close()
|
||||
}}
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-[#020D16]/[0.92] p-[18px] backdrop-blur-[10px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
onClick={close}
|
||||
className="fixed top-[18px] right-[18px] size-[46px] cursor-pointer rounded-full border border-white/25 bg-white/[0.09] text-[25px] text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
alt={image.alt}
|
||||
src={image.image}
|
||||
className="max-h-[88vh] max-w-[min(1500px,96vw)] rounded-[22px] shadow-[0_30px_100px_rgb(0_0_0/0.55)]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
|
||||
/** `.container` — 1200px max, 18px gutters that grow to 28px from 640px up. */
|
||||
export function Container({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'mx-auto w-[min(calc(100%_-_36px),var(--container-page))] sm:w-[min(calc(100%_-_56px),var(--container-page))]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small uppercase teal kicker with the leading rule. */
|
||||
export function Eyebrow({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'mb-[18px] inline-flex items-center gap-[9px] text-[12px] font-extrabold tracking-[0.13em] text-teal uppercase',
|
||||
"before:h-[2px] before:w-[23px] before:rounded-[3px] before:bg-current before:content-['']",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pill badge with the glowing dot, used in the hero and the CTA block. */
|
||||
export function Label({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-[8px] rounded-full border border-teal/35 bg-teal/[0.08] px-[13px] py-[9px] text-[12px] font-extrabold tracking-[0.07em] text-teal-bright uppercase">
|
||||
<LabelDot />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelDot() {
|
||||
return <span className="size-[7px] rounded-full bg-teal shadow-[0_0_16px_rgb(0_196_180/0.75)]" />
|
||||
}
|
||||
|
||||
export function SectionTitle({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<h2 className={cx('mb-[18px] max-w-[920px] text-[clamp(34px,5vw,62px)] leading-[1.01]', className)}>{children}</h2>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionLead({ tone = 'light', children }: { tone?: 'light' | 'dark'; children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cx(
|
||||
'max-w-[760px] text-[clamp(17px,2vw,21px)] leading-[1.55]',
|
||||
tone === 'dark' ? 'text-muted-dark' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHead({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
tone = 'light',
|
||||
className,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: ReactNode
|
||||
lead?: ReactNode
|
||||
tone?: 'light' | 'dark'
|
||||
className?: string
|
||||
}) {
|
||||
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cx('mb-[clamp(34px,5vw,58px)] flex flex-col gap-[8px]', revealClass, className)}>
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTones = {
|
||||
paper: '',
|
||||
white: 'bg-white',
|
||||
dark: 'bg-navy-deep text-white',
|
||||
navy: 'bg-navy text-white',
|
||||
}
|
||||
|
||||
/** `.section` — the shared vertical rhythm and background variants. */
|
||||
export function Section({
|
||||
id,
|
||||
tone = 'paper',
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
id?: string
|
||||
tone?: keyof typeof sectionTones
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={cx('relative py-section', sectionTones[tone], className)}>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { clubFormats } from '../data/content'
|
||||
import { submitLead } from '../lib/lead'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Button } from './Button'
|
||||
import { ConsentCheckbox, Honeypot, SelectField, TextField, TextareaField } from './FormField'
|
||||
import { ArrowRightIcon } from './Icons'
|
||||
|
||||
const EMPTY = {
|
||||
name: '',
|
||||
company: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
club_format: clubFormats[0],
|
||||
comment: '',
|
||||
}
|
||||
|
||||
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
|
||||
|
||||
export function LeadForm({ configuration, commentPrefill }: { configuration: string; commentPrefill: string }) {
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [values, setValues] = useState(EMPTY)
|
||||
const [consent, setConsent] = useState(false)
|
||||
const [honeypot, setHoneypot] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
|
||||
|
||||
// The constructor's "получить эту конфигурацию" CTA writes into the comment.
|
||||
useEffect(() => {
|
||||
if (commentPrefill) setValues((current) => ({ ...current, comment: commentPrefill }))
|
||||
}, [commentPrefill])
|
||||
|
||||
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 (!form.checkValidity()) {
|
||||
form.reportValidity()
|
||||
setStatus({ text: 'Проверьте обязательные поля и согласие.', tone: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
if (honeypot) return
|
||||
|
||||
setPending(true)
|
||||
setStatus({ text: '', tone: 'idle' })
|
||||
|
||||
const result = await submitLead('request', { ...values, configuration, website: honeypot })
|
||||
|
||||
if (result.ok) {
|
||||
setValues(EMPTY)
|
||||
setConsent(false)
|
||||
setStatus({ text: 'Спасибо. Заявка отправлена — специалист свяжется с вами.', tone: 'success' })
|
||||
} else {
|
||||
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
|
||||
}
|
||||
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
id="leadForm"
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="bg-white p-[clamp(24px,4vw,42px)] text-ink"
|
||||
>
|
||||
<h3 className="mb-[8px] text-[28px]">Получить конфигурацию</h3>
|
||||
<p className="mb-[23px] text-[13px] text-muted">
|
||||
Выбранный в конструкторе состав автоматически добавится в заявку.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-[12px] sm:grid-cols-[1fr_1fr]">
|
||||
<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"
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
placeholder="+7 999 000-00-00"
|
||||
required
|
||||
value={values.phone}
|
||||
onChange={set('phone')}
|
||||
/>
|
||||
<TextField
|
||||
id="email"
|
||||
label="Email *"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="name@company.ru"
|
||||
required
|
||||
value={values.email}
|
||||
onChange={set('email')}
|
||||
/>
|
||||
<TextField
|
||||
id="city"
|
||||
label="Город"
|
||||
name="city"
|
||||
autoComplete="address-level2"
|
||||
placeholder="Город проекта"
|
||||
value={values.city}
|
||||
onChange={set('city')}
|
||||
/>
|
||||
<SelectField
|
||||
id="clubFormat"
|
||||
label="Формат"
|
||||
name="club_format"
|
||||
options={clubFormats}
|
||||
value={values.club_format}
|
||||
onChange={set('club_format')}
|
||||
/>
|
||||
<TextareaField
|
||||
id="comment"
|
||||
label="Комментарий"
|
||||
name="comment"
|
||||
full
|
||||
placeholder="Площадь, поток, задачи клуба, наличие медицинской лицензии"
|
||||
value={values.comment}
|
||||
onChange={set('comment')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input name="configuration" type="hidden" value={configuration} readOnly />
|
||||
<Honeypot value={honeypot} onChange={setHoneypot} />
|
||||
<ConsentCheckbox checked={consent} onChange={setConsent} />
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Отправляем…' : 'Получить предложение'}
|
||||
{pending ? null : <ArrowRightIcon />}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cx(
|
||||
'mt-[11px] min-h-[20px] text-[12px]',
|
||||
status.tone === 'success' && 'text-[#087F73]',
|
||||
status.tone === 'error' && 'text-[#A14D12]',
|
||||
status.tone === 'idle' && 'text-muted',
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { marketBars, marketInsights, marketSources } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { Container, Eyebrow, Section, SectionHead } from './Layout'
|
||||
|
||||
export function MarketSection() {
|
||||
const chart = useReveal<HTMLDivElement>()
|
||||
const insights = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="market" tone="white">
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Динамика рынка"
|
||||
title="Фитнес рынок растет, но удерживать выручку только за счет удорожания абонемента становится все сложнее."
|
||||
lead="Клубу нужен принципиально новый продукт внутри сетки услуг: высокомаржинальный сервис восстановления, который увеличивает ARPU, LTV и удерживает людей в клубе."
|
||||
/>
|
||||
|
||||
<div className="grid gap-[26px] md:grid-cols-[1.18fr_0.82fr] md:items-stretch">
|
||||
<div
|
||||
ref={chart.ref}
|
||||
className={`overflow-hidden rounded-panel border border-navy/[0.12] bg-white p-[clamp(22px,4vw,40px)] shadow-soft ${chart.revealClass}`}
|
||||
>
|
||||
<div
|
||||
className="relative grid h-[300px] grid-cols-[repeat(3,1fr)] items-end gap-[14px] pt-[30px] phone:h-[255px]
|
||||
before:absolute before:inset-x-0 before:top-[33%] before:h-px before:bg-navy/[0.09] before:content-['']
|
||||
after:absolute after:inset-x-0 after:top-[66%] after:h-px after:bg-navy/[0.09] after:content-['']"
|
||||
aria-label="Оборот российского фитнес-рынка: 2024 — 263 млрд рублей, 2025 — 316,5 млрд рублей, прогноз 2026 — 365 млрд рублей"
|
||||
>
|
||||
{marketBars.map((bar) => (
|
||||
<div
|
||||
key={bar.year}
|
||||
className={`relative min-h-[65px] origin-bottom rounded-t-[18px] rounded-b-[6px]
|
||||
[transition:transform_1s_cubic-bezier(.2,.8,.2,1),opacity_.8s]
|
||||
${chart.visible ? 'scale-y-100 opacity-100' : 'scale-y-[0.1] opacity-35'}`}
|
||||
style={{ height: bar.height, background: bar.gradient }}
|
||||
>
|
||||
<span className="absolute -top-[43px] left-1/2 -translate-x-1/2 text-[clamp(19px,2.3vw,28px)] font-black whitespace-nowrap text-navy phone:text-[17px]">
|
||||
{bar.value}
|
||||
</span>
|
||||
<span className="absolute top-[12px] left-1/2 -translate-x-1/2 rounded-full bg-white/[0.82] px-[8px] py-[6px] text-[11px] font-black whitespace-nowrap text-navy">
|
||||
{bar.growth}
|
||||
</span>
|
||||
<span className="absolute -bottom-[31px] left-1/2 -translate-x-1/2 text-[13px] font-extrabold whitespace-nowrap text-muted">
|
||||
{bar.year}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-[54px] flex items-center justify-between gap-[12px] text-[12px] text-muted">
|
||||
<span>Оборот фитнес-услуг в России</span>
|
||||
<strong>темп: 23% → 20% → 15%</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={insights.ref}
|
||||
className={`overflow-hidden rounded-panel border border-transparent bg-navy p-[clamp(22px,4vw,40px)] text-white shadow-soft ${insights.revealClass}`}
|
||||
>
|
||||
<Eyebrow>Что меняется для собственника</Eyebrow>
|
||||
|
||||
<div className="mt-[21px] grid gap-[13px]">
|
||||
{marketInsights.map((insight) => (
|
||||
<div
|
||||
key={insight.title}
|
||||
className="grid grid-cols-[45px_1fr] items-start gap-[14px] rounded-[18px] border border-white/10 bg-white/[0.075] p-[17px]"
|
||||
>
|
||||
<div className="grid size-[45px] place-items-center rounded-[14px] bg-teal/[0.14] text-[14px] font-black text-teal">
|
||||
{insight.num}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="mt-px mb-[4px] block text-[16px]">{insight.title}</strong>
|
||||
<p className="text-[13px] text-[#B6C8D4]">{insight.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-[18px] mb-[1em] text-[11px] leading-[1.5] text-muted">
|
||||
Источники:{' '}
|
||||
{marketSources.map((source, index) => (
|
||||
<span key={source.href}>
|
||||
<a
|
||||
className="underline underline-offset-[3px]"
|
||||
href={source.href}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{source.label}
|
||||
</a>
|
||||
{index < marketSources.length - 1 ? '; ' : '.'}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cx } from '../lib/cx'
|
||||
import { ButtonLink, buttonClass } from './Button'
|
||||
import { PhoneIcon } from './Icons'
|
||||
|
||||
export function MobileCta({ show, onCallbackClick }: { show: boolean; onCallbackClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed inset-x-[12px] bottom-[12px] z-45 flex gap-[6px] rounded-full border border-white/[0.14] bg-navy-deep/90 p-[6px] shadow-[0_18px_48px_rgb(0_0_0/0.25)] backdrop-blur-[15px]',
|
||||
'transition-transform duration-350 md:hidden',
|
||||
show ? 'translate-y-0' : 'translate-y-[130%]',
|
||||
)}
|
||||
>
|
||||
<ButtonLink href="#request" size="min-h-[47px] px-[22px]" className="w-full text-[13px]">
|
||||
Получить конфигурацию
|
||||
</ButtonLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCallbackClick}
|
||||
aria-label="Заказать обратный звонок"
|
||||
className={buttonClass('ghost', 'shrink-0 [&>svg]:size-[18px]', 'size-[47px] p-0')}
|
||||
>
|
||||
<PhoneIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { programs } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Section, SectionHead } from './Layout'
|
||||
|
||||
export function ProgramsSection() {
|
||||
const disclaimer = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="programs" tone="white">
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Продукт для клиента"
|
||||
title="Продаётся не аппарат. Продаётся понятный маршрут"
|
||||
lead="Состав программы зависит от цели клиента, этапа тренировок и допуска специалиста. Клуб получает продукт, который проще объяснять, измерять и продавать курсом."
|
||||
/>
|
||||
|
||||
<div className="no-scrollbar flex snap-x snap-mandatory gap-[13px] overflow-auto px-px pt-[3px] pb-[18px] md:grid md:grid-cols-2 md:gap-[16px] md:overflow-visible">
|
||||
{programs.map((program) => (
|
||||
<ProgramCard key={program.title} {...program} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={disclaimer.ref}
|
||||
className={cx(
|
||||
'mt-[12px] rounded-[18px] bg-navy/[0.05] px-[18px] py-[16px] text-[12px] text-muted',
|
||||
disclaimer.revealClass,
|
||||
)}
|
||||
>
|
||||
Корректная коммуникация: не обещаем «сжигание жира», рост мышц от аппарата, лечение травмы без специалиста или
|
||||
гарантированный спортивный результат. В медицинском формате используются допуск, назначение, протокол и
|
||||
проверка противопоказаний.
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramCard({ tag, title, text, devices, note }: (typeof programs)[number]) {
|
||||
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'flex min-h-[215px] flex-none snap-start flex-col basis-[min(83vw,330px)] rounded-[23px] border border-navy/[0.12] bg-white p-[24px]',
|
||||
'sm:basis-[320px] md:min-h-[410px] md:min-w-0 phone:basis-[min(90vw,360px)]',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<span className="mb-[18px] inline-flex max-w-full self-start rounded-full bg-teal-pale px-[10px] py-[7px] text-left text-[10px] leading-[1.25] font-black tracking-[0.06em] text-navy uppercase">
|
||||
{tag}
|
||||
</span>
|
||||
<h3 className="mb-[9px] text-[clamp(20px,2.1vw,25px)] leading-[1.15] [overflow-wrap:anywhere]">{title}</h3>
|
||||
<p className="mb-[18px] flex-1 text-[14px] leading-[1.55] text-muted">{text}</p>
|
||||
<div className="border-t border-navy/[0.12] pt-[15px] text-[13px] leading-[1.5] font-extrabold text-navy">
|
||||
<b className="text-navy">Процедуры и связки: </b>
|
||||
{devices}
|
||||
<span className="mt-[6px] block font-semibold text-muted">{note}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ctaChecklist } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { CheckIcon } from './Icons'
|
||||
import { Container, Label, Section } from './Layout'
|
||||
import { LeadForm } from './LeadForm'
|
||||
|
||||
export function RequestSection({ configuration, commentPrefill }: { configuration: string; commentPrefill: string }) {
|
||||
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="request" tone="white">
|
||||
<Container>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'relative isolate overflow-hidden rounded-[38px] bg-[linear-gradient(135deg,#0A2540,#061B2F)] text-white shadow-deep',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -top-[190px] -right-[180px] -z-1 size-[470px] rounded-full bg-teal/[0.28] blur-[95px]"
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-[1.03fr_0.97fr]">
|
||||
<div className="p-[clamp(28px,5vw,58px)]">
|
||||
<Label>Персональная конфигурация</Label>
|
||||
<h2 className="mb-[20px] max-w-[680px] text-[clamp(36px,5.5vw,66px)] leading-none">
|
||||
Подберем индивидуальный комплект оборудования точно под формат вашего клуба.
|
||||
</h2>
|
||||
<p className="max-w-[610px] text-[16px] text-[#B6C9D4]">
|
||||
Оставьте контакты — проведём первичный аудит потока и площади, предложим состав оборудования, формат
|
||||
размещения и визуальную концепцию.
|
||||
</p>
|
||||
|
||||
<div className="mt-[27px] grid gap-[10px]">
|
||||
{ctaChecklist.map((item) => (
|
||||
<div key={item} className="grid grid-cols-[24px_1fr] items-center gap-[9px] text-[13px] text-[#D7E4EB]">
|
||||
<i className="grid size-[23px] place-items-center rounded-full bg-teal/[0.15] text-teal [&>svg]:size-[13px]">
|
||||
<CheckIcon />
|
||||
</i>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LeadForm configuration={configuration} commentPrefill={commentPrefill} />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { contacts } from '../data/content'
|
||||
import { Container } from './Layout'
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="bg-footer py-[32px] text-[11px] text-[#8FA6B4]">
|
||||
<Container className="flex flex-col gap-[18px] md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<strong className="text-white">ЭКЗО ГРУПП</strong>
|
||||
<br />
|
||||
Российские технологии реабилитации
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[16px] [&>a:hover]:text-white">
|
||||
<a href={contacts.phoneHref}>{contacts.phone}</a>
|
||||
<a href={`mailto:${contacts.email}`}>{contacts.email}</a>
|
||||
<a href={contacts.siteHref} rel="noopener" target="_blank">
|
||||
{contacts.site}
|
||||
</a>
|
||||
</div>
|
||||
<div>© 2026 ООО «ЭКЗО ГРУПП»</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import logo from '../assets/images/exo-logo.png'
|
||||
import { navLinks } from '../data/content'
|
||||
import { cx } from '../lib/cx'
|
||||
import { ButtonLink, buttonClass } from './Button'
|
||||
import { PhoneIcon } from './Icons'
|
||||
import { Container } from './Layout'
|
||||
|
||||
export function SiteHeader({ scrolled, onCallbackClick }: { scrolled: boolean; onCallbackClick: () => void }) {
|
||||
return (
|
||||
<header
|
||||
className={cx(
|
||||
'fixed inset-x-0 top-0 z-50 py-[13px] transition-[background-color,box-shadow,backdrop-filter] duration-300',
|
||||
scrolled && 'bg-navy-deep/[0.88] shadow-[0_10px_35px_rgb(0_0_0/0.15)] backdrop-blur-[16px]',
|
||||
)}
|
||||
>
|
||||
<Container className="flex items-center justify-between gap-[18px] phone:gap-[10px]">
|
||||
<a className="inline-flex min-w-0 items-center text-white" href="#top" aria-label="Экзо Групп — на первый экран">
|
||||
<img
|
||||
alt="Экзо Групп — российские технологии реабилитации"
|
||||
className="h-auto max-h-[54px] w-[clamp(150px,19vw,215px)] object-contain object-left narrow:w-[145px] phone:w-[128px]"
|
||||
src={logo}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<nav
|
||||
aria-label="Навигация по странице"
|
||||
className="hidden gap-[24px] text-[13px] font-bold text-white/[0.82] md:flex"
|
||||
>
|
||||
{navLinks.map((link) => (
|
||||
<a key={link.href} href={link.href} className="hover:text-teal">
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-[9px] narrow:gap-[7px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCallbackClick}
|
||||
aria-label="Заказать обратный звонок"
|
||||
className={buttonClass(
|
||||
'ghost',
|
||||
'text-[13px] whitespace-nowrap [&>svg]:size-[17px] narrow:[&>svg]:size-[18px]',
|
||||
// Below 760px the label is dropped from the layout entirely, so
|
||||
// the icon is the only flex item and centres itself in a circle.
|
||||
'min-h-[42px] px-[14px] narrow:size-[42px] narrow:px-0',
|
||||
)}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span className="narrow:hidden">Звонок</span>
|
||||
</button>
|
||||
<ButtonLink
|
||||
href="#request"
|
||||
size="min-h-[42px] px-[16px] narrow:px-[13px] phone:min-h-[40px] phone:px-[11px]"
|
||||
className="text-[13px] whitespace-nowrap phone:text-[11px]"
|
||||
>
|
||||
Получить конфигурацию
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ecosystem, legalOptions, proofStats } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Eyebrow, Section, SectionLead, SectionTitle } from './Layout'
|
||||
|
||||
export function WhyExoSection() {
|
||||
const copy = useReveal<HTMLDivElement>()
|
||||
const legal = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<Section id="why-exo" tone="dark">
|
||||
<Container className="grid items-center gap-[28px] md:grid-cols-[1.1fr_0.9fr]">
|
||||
<div ref={copy.ref} className={copy.revealClass}>
|
||||
<Eyebrow>Почему Экзо Групп</Eyebrow>
|
||||
<SectionTitle>
|
||||
Вы входите в готовую экосистему, где каждый бизнес-процесс уже автоматизирован.
|
||||
</SectionTitle>
|
||||
<SectionLead tone="dark">
|
||||
Собственное производство, планировка пространства, меню готовых программ, обучение команды. Все это работает
|
||||
на окупаемость вашего клуба с первого дня.
|
||||
</SectionLead>
|
||||
|
||||
<div className="grid grid-cols-[repeat(2,1fr)] gap-[10px]">
|
||||
{proofStats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-[20px] border border-white/10 bg-white/[0.07] p-[19px]">
|
||||
<strong className="block text-[clamp(27px,4vw,44px)] leading-none text-teal">{stat.value}</strong>
|
||||
<span className="text-[11px] text-mist">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-[22px] grid grid-cols-[repeat(2,1fr)] gap-[10px]">
|
||||
{ecosystem.map((item) => (
|
||||
<div key={item.title} className="rounded-[17px] bg-white/[0.06] p-[15px] text-[12px] text-[#C6D5DE]">
|
||||
<b className="mb-[4px] block text-white">{item.title}</b>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
ref={legal.ref}
|
||||
className={cx('rounded-[28px] bg-white p-[clamp(23px,4vw,36px)] text-ink', legal.revealClass)}
|
||||
>
|
||||
<Eyebrow>Юридическая рамка</Eyebrow>
|
||||
<h3 className="mb-[18px] text-[27px]">Сценарий фиксируется до запуска</h3>
|
||||
{legalOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.title}
|
||||
className={cx('py-[16px]', index === 0 ? 'pt-0' : 'border-t border-navy/[0.12]')}
|
||||
>
|
||||
<strong className="flex items-center gap-[9px] text-[15px]">
|
||||
<i className="size-[9px] rounded-full bg-teal" />
|
||||
{option.title}
|
||||
</strong>
|
||||
<p className="mt-[7px] text-[12px] text-muted">{option.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import galleryGlass from '../assets/images/gallery-glass-zone.webp'
|
||||
import galleryPremium from '../assets/images/gallery-premium-room.webp'
|
||||
import galleryPt from '../assets/images/gallery-pt-stretch.webp'
|
||||
import galleryFlagship from '../assets/images/gallery-flagship.webp'
|
||||
|
||||
export const contacts = {
|
||||
phone: '+7 939 717-80-80',
|
||||
phoneHref: 'tel:+79397178080',
|
||||
email: 'info@exotherapy.ru',
|
||||
site: 'экзотерапия.рф',
|
||||
siteHref: 'https://экзотерапия.рф',
|
||||
}
|
||||
|
||||
export const navLinks = [
|
||||
{ href: '#market', label: 'Рынок' },
|
||||
{ href: '#constructor', label: 'Конструктор' },
|
||||
{ href: '#formats', label: 'Визуализации' },
|
||||
{ href: '#why-exo', label: 'Экосистема' },
|
||||
]
|
||||
|
||||
export const heroFacts = [
|
||||
{ title: '3 аппарата', text: 'компактное ядро под задачи клуба' },
|
||||
{ title: '5 аппаратов', text: 'полный маршрут recovery и спорта' },
|
||||
{
|
||||
title: 'Окупаемость',
|
||||
emphasis: 'от 6 месяцев',
|
||||
text: ' — быстрый возврат инвестиций без риска заморозить деньги в оборудовании',
|
||||
},
|
||||
]
|
||||
|
||||
export const heroSteps = [
|
||||
{ num: '01', text: 'Удержание клиента при перегрузке и паузе в тренировках' },
|
||||
{ num: '02', text: 'Процедуры, курсы и пакеты на действующей базе' },
|
||||
{ num: '03', text: 'Инструмент рекомендации для тренеров и ресепшена' },
|
||||
{ num: '04', text: 'Визуально сильная зона для premium-позиционирования' },
|
||||
]
|
||||
|
||||
export const marketBars = [
|
||||
{ height: '72%', value: '263 млрд ₽', growth: '+23%', year: '2024', gradient: 'linear-gradient(180deg,#3BE4D6,#00C4B4)' },
|
||||
{ height: '87%', value: '316,5 млрд ₽', growth: '+20%', year: '2025', gradient: 'linear-gradient(180deg,#62D4EC,#189BC4)' },
|
||||
{ height: '100%', value: '365 млрд ₽', growth: '+15% прогноз', year: '2026П', gradient: 'linear-gradient(180deg,#FFB16C,#FF7A1A)' },
|
||||
]
|
||||
|
||||
export const marketInsights = [
|
||||
{
|
||||
num: '>50%',
|
||||
title: 'Рост всё больше ценовой',
|
||||
text: 'В I полугодии 2025 года повышение цен обеспечило более половины прироста рынка.',
|
||||
},
|
||||
{
|
||||
num: '12%',
|
||||
title: 'Подписка меняет модель',
|
||||
text: 'Доля рекуррентных продаж карт достигла 12% в I квартале 2025 года, в Москве приблизилась к 20%.',
|
||||
},
|
||||
{
|
||||
num: '66%',
|
||||
title: 'Удержание — ключевой резерв',
|
||||
text: 'Международный benchmark HFA: средняя годовая удерживаемость участников — около двух третей.',
|
||||
},
|
||||
]
|
||||
|
||||
export const marketSources = [
|
||||
{ href: 'https://fitnessdata.ru/tpost/21gna911z1-novii-vipusk-novostei-industrii', label: 'FitnessData, итоги 2025 и прогноз 2026' },
|
||||
{ href: 'https://fitnessdata.ru/tpost/exl57eeyx1-issledovanie-rinka-fitnes-uslug-rossii-p', label: 'FitnessData, I полугодие 2025' },
|
||||
{ href: 'https://www.healthandfitness.org/2025-fitness-industry-benchmarking-report/', label: 'HFA Benchmarking Report 2025' },
|
||||
]
|
||||
|
||||
export const econMetrics = [
|
||||
{ value: '>50%', label: 'роста рынка — ценовой', note: 'дальше повышать карту всё сложнее' },
|
||||
{ value: '34 из 100', label: 'клиентов не удерживаются', note: 'расчёт из benchmark 66%' },
|
||||
{ value: '≈88%', label: 'продаж карт не рекуррентные', note: 'рекуррентная доля — 12%' },
|
||||
{ value: '23→15%', label: 'замедление темпа рынка', note: '2024 → прогноз 2026' },
|
||||
]
|
||||
|
||||
export const econLoss = [
|
||||
{
|
||||
num: '01',
|
||||
title: 'День без тренировки не монетизируется',
|
||||
text: 'При усталости, перегрузке или паузе клиент не приходит в клуб и не покупает дополнительный сервис.',
|
||||
},
|
||||
{
|
||||
num: '02',
|
||||
title: 'Запрос уходит во внешние студии',
|
||||
text: 'Массаж, восстановление и return-to-sport покупаются за пределами клуба.',
|
||||
},
|
||||
{
|
||||
num: '03',
|
||||
title: 'ARPU растёт только вместе с ценой карты',
|
||||
text: 'Новый доход появляется через подорожание, а не через новый продукт для действующей базы.',
|
||||
},
|
||||
]
|
||||
|
||||
export const econGain = [
|
||||
{
|
||||
num: '01',
|
||||
title: 'Разовые recovery-сессии',
|
||||
text: 'Отдельная причина прийти в клуб после нагрузки или даже в день без тренировки.',
|
||||
},
|
||||
{
|
||||
num: '02',
|
||||
title: 'Короткие курсы и пакеты',
|
||||
text: 'Восстановление ног, подвижность, перезагрузка и возврат к нагрузке повышают ARPU.',
|
||||
},
|
||||
{
|
||||
num: '03',
|
||||
title: 'Premium и рекомендации тренера',
|
||||
text: 'Recovery усиливает membership, помогает удержанию и возвращает клиента к регулярной нагрузке.',
|
||||
},
|
||||
]
|
||||
|
||||
export const econOps = [
|
||||
{ value: '3 аппарата', label: 'компактное recovery-ядро' },
|
||||
{ value: '5 аппаратов', label: 'полная платформа клуба' },
|
||||
{ value: 'от 6 мес.', label: 'заявленный ориентир окупаемости' },
|
||||
{ value: '4 канала', label: 'сессии, пакеты, premium, PT' },
|
||||
]
|
||||
|
||||
export const econFormula = [
|
||||
{ badge: '1', lines: ['Оплаченных программ', 'в день'] },
|
||||
{ badge: '2', lines: ['Средний чек', 'программы'] },
|
||||
{ badge: '3', lines: ['Рабочих дней', 'в месяц'] },
|
||||
{ badge: '₽', lines: ['Дополнительная', 'выручка в месяц'] },
|
||||
]
|
||||
|
||||
export const benefits = [
|
||||
{
|
||||
index: '01 / LTV',
|
||||
title: 'Удержание',
|
||||
text: 'Появляется причина прийти даже в день без тренировки и не выпадать из привычного плана из-за перегрузки или дискомфорта.',
|
||||
},
|
||||
{
|
||||
index: '02 / ARPU',
|
||||
title: 'Новая выручка',
|
||||
text: 'Процедуры, короткие курсы и recovery-пакеты монетизируют действующую базу без продажи ещё одной клубной карты.',
|
||||
},
|
||||
{
|
||||
index: '03 / SALES',
|
||||
title: 'Роль тренера',
|
||||
text: 'Тренер замечает запрос, рекомендует маршрут восстановления и получает инструмент для возвращения клиента к нагрузке.',
|
||||
},
|
||||
{
|
||||
index: '04 / BRAND',
|
||||
title: 'Новая аудитория',
|
||||
text: 'Premium-клиенты, спортсмены-любители, взрослые и корпоративные клиенты получают дополнительный аргумент выбрать клуб.',
|
||||
},
|
||||
]
|
||||
|
||||
export const glossary = [
|
||||
{ term: 'LTV', text: 'Lifetime Value — совокупная ценность клиента за весь период отношений с клубом.' },
|
||||
{ term: 'ARPU', text: 'Average Revenue Per User — средняя выручка на одного клиента.' },
|
||||
{ term: 'SALES', text: 'Sales — продажи и коммерческая конверсия.' },
|
||||
{ term: 'BRAND', text: 'Brand — бренд, его ценность и восприятие аудиторией.' },
|
||||
]
|
||||
|
||||
export const programs = [
|
||||
{
|
||||
tag: 'СИЛА И МЫШЦЫ',
|
||||
title: 'Готовность к тяжелым весам (Пауэрлифтинг / Бодибилдинг)',
|
||||
text: 'Комплексная подготовка опорно-двигательного аппарата к предельным нагрузкам. Устранение мышечной зажатости, проработка триггерных точек, фасциальный релиз и увеличение эластичности мягких тканей для безопасного приседа, жима и тяги.',
|
||||
devices: 'ЭкзоТекар + ЭкзоТерапия',
|
||||
note: 'Глубокий прогрев соединительной ткани, снятие блоков, подготовка нервной системы к взрывной работе.',
|
||||
},
|
||||
{
|
||||
tag: 'БЕГ / КАРДИО',
|
||||
title: 'Восстановление ног и выносливости (Легкая атлетика / Сайклинг)',
|
||||
text: 'Мощный лимфодренаж и выведение продуктов распада (молочной кислоты) после длительного бега, функционального тренинга или «дня ног». Снятие ощущения «гудящих» мышц, устранение застойных явлений и запуск быстрой регенерации.',
|
||||
devices: 'ЭкзоПресс + ЭкзоВодород',
|
||||
note: 'Прессотерапия для вытеснения венозной крови и межтканевой жидкости + мощное антиоксидантное насыщение против клеточного стресса.',
|
||||
},
|
||||
{
|
||||
tag: 'ПОДВИЖНОСТЬ И ГИБКОСТЬ',
|
||||
title: 'Локальный комфорт и амплитуда (Йога / Растяжка / Пилатес)',
|
||||
text: 'Увеличение подвижности заблокированных суставов (особенно тазобедренных и плечевых) и снятие жесткости фасций. Идеально для тех, кто хочет улучшить шпагат, разгрузить позвоночник и убрать скованность движений после сидячей работы.',
|
||||
devices: 'ЭкзоТекар + ЭкзоТерапия',
|
||||
note: 'Фасциальный массаж с глубоким прогревом для стимуляции выработки коллагена и эластичности.',
|
||||
},
|
||||
{
|
||||
tag: 'ПОСЛЕ НАГРУЗКИ',
|
||||
title: 'Перезагрузка после активного дня (Кроссфит / Единоборства)',
|
||||
text: 'Быстрый перевод организма из режима максимального стресса (симпатическая нервная система) в режим глубокого отдыха (парасимпатика). Снижение общего воспалительного тонуса мышц, нормализация пульса и подготовка к комфортному ночному сну.',
|
||||
devices: 'ЭкзоТерапия + ЭкзоПресс',
|
||||
note: 'Стимуляция нервных окончаний для снятия осевой нагрузки с позвоночника + лимфодренажный массаж всего тела.',
|
||||
},
|
||||
{
|
||||
tag: 'ВОЗВРАТ В СПОРТ',
|
||||
title: 'Безопасный возврат к тренировкам (После травм и пауз)',
|
||||
text: 'Мягкая и безопасная реабилитация внутри фитнес-клуба. Ускорение заживления растяжений, точечное снятие хронических воспалений в сухожилиях, восстановление мышечного тонуса и координации движений в пространстве (проприоцепции).',
|
||||
devices: 'Комбинация по протоколу: ЭкзоЛазер B + ЭкзоИмпульс',
|
||||
note: 'Локальная фотобиомодуляция для регенерации связок + глубокая электромагнитная стимуляция для пробуждения атрофированных мышц.',
|
||||
},
|
||||
{
|
||||
tag: 'СТРЕСС И ПЕРЕЗАГРУЗКА',
|
||||
title: 'День восстановления (Фитнес без тренировки)',
|
||||
text: 'Сценарий посещения клуба без физических нагрузок. Полная релаксация, ликвидация умственного выгорания, синдрома хронической усталости и головных болей напряжения. Возвращение телесного комфорта и легкости за один сеанс.',
|
||||
devices: 'ЭкзоВодород + ЭкзоПресс',
|
||||
note: 'Ингаляции чистым водородом для снижения уровня стресса на клеточном уровне + мягкий релакс-массаж ног и тела.',
|
||||
},
|
||||
]
|
||||
|
||||
export const gallery = [
|
||||
{
|
||||
badge: 'Рядом с залом',
|
||||
title: 'Стеклянная зона',
|
||||
text: 'Видимость помогает продавать сервис через тренеров и ресепшен без отдельной рекламной кампании.',
|
||||
image: galleryGlass,
|
||||
alt: 'Стеклянная зона восстановления рядом с тренажёрным залом',
|
||||
},
|
||||
{
|
||||
badge: 'Premium room',
|
||||
title: 'Клубный premium-сервис',
|
||||
text: 'Recovery воспринимается как часть membership и усиливает ценность клуба, а не как отдельный медицинский кабинет.',
|
||||
image: galleryPremium,
|
||||
alt: 'Премиальная зона восстановления в фитнес-клубе',
|
||||
},
|
||||
{
|
||||
badge: 'PT / stretch',
|
||||
title: 'Продолжение тренировки',
|
||||
text: 'Расположение около PT и stretch-зоны делает маршрут «нагрузка → восстановление» естественным.',
|
||||
image: galleryPt,
|
||||
alt: 'Зона восстановления рядом с персональными тренировками и растяжкой',
|
||||
},
|
||||
{
|
||||
badge: 'Flagship',
|
||||
title: 'High-tech studio',
|
||||
text: 'Визуально сильный объект для PR, видео, фотоконтента, premium-позиционирования и масштабирования сети.',
|
||||
image: galleryFlagship,
|
||||
alt: 'Высокотехнологичная performance studio в фитнес-клубе',
|
||||
},
|
||||
]
|
||||
|
||||
export const proofStats = [
|
||||
{ value: '4', label: 'собственные клиники' },
|
||||
{ value: '70+', label: 'клиник-партнёров' },
|
||||
{ value: '60+', label: 'медицинских учреждений' },
|
||||
{ value: '2 млн+', label: 'проведённых процедур' },
|
||||
]
|
||||
|
||||
export const ecosystem = [
|
||||
{ title: 'Оборудование', text: 'подбор и поставка' },
|
||||
{ title: 'Пространство', text: 'планировка и дизайн' },
|
||||
{ title: 'Программы', text: 'методики и курсы' },
|
||||
{ title: 'Обучение', text: 'операторы и продажи' },
|
||||
{ title: 'Аналитика', text: 'KPI и повторные визиты' },
|
||||
{ title: 'Масштаб', text: 'типовой формат для сети' },
|
||||
]
|
||||
|
||||
export const legalOptions = [
|
||||
{
|
||||
title: 'Медицинская Recovery Zone',
|
||||
text: 'Врач или медицинский специалист, допуск, назначение, протокол, противопоказания, санитарный регламент и лицензируемая деятельность.',
|
||||
},
|
||||
{
|
||||
title: 'Wellness-only',
|
||||
text: 'Отдельная модель без диагнозов, лечебных обещаний и медицинских показаний — recovery, расслабление и забота о самочувствии.',
|
||||
},
|
||||
{
|
||||
title: 'Планировка',
|
||||
text: 'Для лицензируемого медицинского формата в материалах EXO предусмотрены решения от 35 м² для двух кабинетов и от 45 м² для трёх.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ctaChecklist = [
|
||||
'состав аппаратов и роли каждого в зоне',
|
||||
'вариант планировки и внешний вид в интерьере клуба',
|
||||
'меню программ, обучение и сценарии рекомендаций',
|
||||
'план запуска и контрольные показатели пилота',
|
||||
]
|
||||
|
||||
export const clubFormats = ['Один клуб', 'Сеть клубов', 'Premium-клуб', 'Студия / performance', 'Пока не определён']
|
||||
@@ -0,0 +1,89 @@
|
||||
import deviceTherapy from '../assets/images/device-therapy.webp'
|
||||
import deviceMagnet from '../assets/images/device-magnet.webp'
|
||||
import deviceTecar from '../assets/images/device-tecar.webp'
|
||||
import devicePress from '../assets/images/device-press.webp'
|
||||
import deviceHydrogen from '../assets/images/device-hydrogen.webp'
|
||||
import configThree from '../assets/images/config-three.webp'
|
||||
import configFive from '../assets/images/config-five.webp'
|
||||
|
||||
export interface Device {
|
||||
name: string
|
||||
role: string
|
||||
benefits: string[]
|
||||
caption: string
|
||||
image: string
|
||||
}
|
||||
|
||||
/** Declaration order drives both the picker grid and the 5-device selection. */
|
||||
export const devices = {
|
||||
therapy: {
|
||||
name: 'ЭкзоТерапия',
|
||||
role: 'флагманский full-body recovery и premium-продукт',
|
||||
benefits: ['заметный якорь зоны', 'восстановительный этап после нагрузки', 'повышает технологичность сервиса'],
|
||||
caption: 'Флагманский full-body recovery',
|
||||
image: deviceTherapy,
|
||||
},
|
||||
magnet: {
|
||||
name: 'ЭкзоМагнит 3 в 1',
|
||||
role: 'medical-core для нейромышечных и опорно-двигательных маршрутов',
|
||||
benefits: ['магнит + УВТ + инфракрасный модуль', 'работа по медицинскому протоколу', 'ядро для спортивной медицины'],
|
||||
caption: 'Нейромышечное и medical-core ядро',
|
||||
image: deviceMagnet,
|
||||
},
|
||||
tecar: {
|
||||
name: 'ЭкзоТекар 3 в 1',
|
||||
role: 'активная локальная работа с тканями, фасциями и подвижностью',
|
||||
benefits: ['TECAR + УВТ + ультразвук', 'инструмент специалиста', 'сильная связь с PT и return-to-sport'],
|
||||
caption: 'Ткани, фасции, локальная работа',
|
||||
image: deviceTecar,
|
||||
},
|
||||
press: {
|
||||
name: 'ЭкзоПресс',
|
||||
role: 'потоковый recovery для ног после бега, leg day и нагрузки',
|
||||
benefits: ['компрессия + тепло / холод', 'понятный сервис для массового спроса', 'легко включается в пакеты'],
|
||||
caption: 'Ноги, компрессия, потоковый формат',
|
||||
image: devicePress,
|
||||
},
|
||||
hydrogen: {
|
||||
name: 'ЭкзоВодород',
|
||||
role: 'автономная lounge-процедура для recovery и recharge',
|
||||
benefits: ['спокойный формат в кресле', 'минимальное участие оператора', 'отдельная причина прийти в recovery-day'],
|
||||
caption: 'Lounge recovery и перезагрузка',
|
||||
image: deviceHydrogen,
|
||||
},
|
||||
} satisfies Record<string, Device>
|
||||
|
||||
export type DeviceId = keyof typeof devices
|
||||
export const deviceIds = Object.keys(devices) as DeviceId[]
|
||||
|
||||
export interface Preset {
|
||||
ids: DeviceId[]
|
||||
title: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
export const presets = {
|
||||
recovery: {
|
||||
ids: ['therapy', 'press', 'hydrogen'],
|
||||
title: 'Recovery Start',
|
||||
subtitle: 'Компактный вход в recovery с флагманской, потоковой и автономной процедурой.',
|
||||
},
|
||||
sport: {
|
||||
ids: ['magnet', 'tecar', 'press'],
|
||||
title: 'Sport Core',
|
||||
subtitle: 'Активное ядро для клуба с сильным PT, беговым, силовым или спортивным направлением.',
|
||||
},
|
||||
premium: {
|
||||
ids: ['therapy', 'tecar', 'hydrogen'],
|
||||
title: 'Premium Mix',
|
||||
subtitle: 'Статусная конфигурация с индивидуальной процедурой, флагманским recovery и lounge-сценарием.',
|
||||
},
|
||||
} satisfies Record<string, Preset>
|
||||
|
||||
export type PresetId = keyof typeof presets
|
||||
export const presetIds = Object.keys(presets) as PresetId[]
|
||||
|
||||
export const configImages = {
|
||||
three: { src: configThree, alt: 'Пример зоны восстановления с тремя аппаратами в фитнес-клубе' },
|
||||
five: { src: configFive, alt: 'Полная зона восстановления с пятью аппаратами' },
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** Freezes background scrolling while a modal is open. */
|
||||
export function useBodyLock(locked: boolean) {
|
||||
useEffect(() => {
|
||||
if (!locked) return
|
||||
document.body.classList.add('is-locked')
|
||||
return () => document.body.classList.remove('is-locked')
|
||||
}, [locked])
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { deviceIds, devices, presets, type DeviceId, type PresetId } from '../data/devices'
|
||||
|
||||
const MAX_IN_THREE_MODE = 3
|
||||
|
||||
export type ConstructorState = ReturnType<typeof useConstructor>
|
||||
|
||||
/**
|
||||
* The 3-vs-5 device picker. Selection order is preserved (the original used a
|
||||
* Set and relied on its insertion order), because it drives the pill list and
|
||||
* decides which device is dropped when a fourth one is picked.
|
||||
*/
|
||||
export function useConstructor() {
|
||||
const [mode, setModeState] = useState<3 | 5>(3)
|
||||
const [selected, setSelected] = useState<DeviceId[]>(presets.recovery.ids)
|
||||
const [preset, setPreset] = useState<PresetId | ''>('recovery')
|
||||
|
||||
const toggleDevice = useCallback(
|
||||
(id: DeviceId) => {
|
||||
if (mode === 5) return
|
||||
setPreset('')
|
||||
setSelected((current) => {
|
||||
if (current.includes(id)) return current.filter((item) => item !== id)
|
||||
const next = current.length >= MAX_IN_THREE_MODE ? current.slice(1) : current
|
||||
return [...next, id]
|
||||
})
|
||||
},
|
||||
[mode],
|
||||
)
|
||||
|
||||
const setMode = useCallback((next: 3 | 5) => {
|
||||
setModeState(next)
|
||||
if (next === 5) {
|
||||
setSelected(deviceIds)
|
||||
setPreset('')
|
||||
} else {
|
||||
setSelected(presets.recovery.ids)
|
||||
setPreset('recovery')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyPreset = useCallback((id: PresetId) => {
|
||||
setModeState(3)
|
||||
setPreset(id)
|
||||
setSelected(presets[id].ids)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setModeState(3)
|
||||
setPreset('recovery')
|
||||
setSelected(presets.recovery.ids)
|
||||
}, [])
|
||||
|
||||
return useMemo(() => {
|
||||
const names = selected.map((id) => devices[id].name)
|
||||
const configString = names.join(', ')
|
||||
|
||||
const matchedPreset = Object.entries(presets).find(
|
||||
([, value]) => value.ids.length === selected.length && value.ids.every((id) => selected.includes(id)),
|
||||
)
|
||||
|
||||
const benefits: string[] = []
|
||||
for (const id of selected) {
|
||||
for (const benefit of devices[id].benefits) {
|
||||
if (!benefits.includes(benefit)) benefits.push(benefit)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
selected,
|
||||
preset,
|
||||
names,
|
||||
toggleDevice,
|
||||
setMode,
|
||||
applyPreset,
|
||||
reset,
|
||||
benefits: benefits.slice(0, 4),
|
||||
isValid: mode === 5 || selected.length === MAX_IN_THREE_MODE,
|
||||
hint:
|
||||
mode === 5
|
||||
? 'Полная линейка выбрана'
|
||||
: selected.length === MAX_IN_THREE_MODE
|
||||
? 'Конфигурация собрана'
|
||||
: `Выберите ещё ${MAX_IN_THREE_MODE - selected.length}`,
|
||||
tag: mode === 5 ? 'Полная EXO-платформа' : 'Конструктор на 3 аппарата',
|
||||
number: mode === 5 ? '05' : String(selected.length).padStart(2, '0'),
|
||||
title:
|
||||
mode === 5
|
||||
? 'Полная зона — 5 аппаратов'
|
||||
: matchedPreset
|
||||
? `${matchedPreset[1].title} — 3 аппарата`
|
||||
: 'Персональный микс — 3 аппарата',
|
||||
subtitle:
|
||||
mode === 5
|
||||
? 'Полный маршрут: active medical-core, premium recovery, потоковые и автономные процедуры.'
|
||||
: matchedPreset
|
||||
? matchedPreset[1].subtitle
|
||||
: 'Состав собран под выбранные вами приоритеты. Итоговая логика уточняется после аудита клуба.',
|
||||
/** Hidden form field sent to amoCRM. */
|
||||
configurationValue: `${mode} аппарата: ${configString}`,
|
||||
/** Pre-filled into the comment box by the "get this configuration" CTA. */
|
||||
requestComment: `Интересует конфигурация: ${mode} аппарата — ${configString}`,
|
||||
}
|
||||
}, [mode, selected, preset, toggleDevice, setMode, applyPreset, reset])
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useEscapeKey(active: boolean, onEscape: () => void) {
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onEscape()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [active, onEscape])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Scroll-in animation, matching the original page: fires once at 12% visibility
|
||||
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
|
||||
* that `prefers-reduced-motion` can neutralise it in one place.
|
||||
*/
|
||||
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || visible) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
setVisible(true)
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
},
|
||||
{ threshold: 0.12 },
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [visible])
|
||||
|
||||
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Two scroll-derived flags from the original page:
|
||||
* - `scrolled` — header gets its blurred background past 35px
|
||||
* - `showMobileCta` — sticky CTA appears in the middle of the page only
|
||||
*/
|
||||
export function useScrollState() {
|
||||
const [state, setState] = useState({ scrolled: false, showMobileCta: false })
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY
|
||||
setState({
|
||||
scrolled: y > 35,
|
||||
showMobileCta: y > window.innerHeight * 0.75 && y < document.body.scrollHeight - window.innerHeight * 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
@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: #123c5b;
|
||||
--color-teal: #00c4b4;
|
||||
--color-teal-bright: #3be4d6;
|
||||
--color-teal-pale: #ddfbf7;
|
||||
--color-orange: #ff7a1a;
|
||||
|
||||
/* Surfaces & text */
|
||||
--color-paper: #f4f8fa;
|
||||
--color-paper-2: #eaf2f5;
|
||||
--color-ink: #0a2540;
|
||||
--color-muted: #61798b;
|
||||
--color-muted-dark: #a9bfcc;
|
||||
--color-mist: #afc4d0;
|
||||
--color-field: #f7fafb;
|
||||
--color-footer: #041726;
|
||||
|
||||
/* Economics section palette */
|
||||
--color-econ-bg: #f4fafb;
|
||||
--color-econ-teal-bright: #39e0d2;
|
||||
--color-econ-teal-deep: #00a99a;
|
||||
--color-econ-teal-ink: #008f84;
|
||||
--color-econ-ice: #eaf9f7;
|
||||
--color-econ-red: #f0656b;
|
||||
--color-econ-red-pale: #ffd7d9;
|
||||
|
||||
/* Radii */
|
||||
--radius-tile: 19px;
|
||||
--radius-block: 26px;
|
||||
--radius-panel: 34px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-deep: 0 30px 90px rgb(4 28 46 / 0.16);
|
||||
--shadow-soft: 0 16px 50px rgb(4 28 46 / 0.1);
|
||||
|
||||
/* Layout rhythm */
|
||||
--container-page: 1200px;
|
||||
--spacing-section: clamp(70px, 8vw, 110px);
|
||||
|
||||
/* Breakpoints — the original used 640 / 900 / 1120 */
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 900px;
|
||||
--breakpoint-lg: 1120px;
|
||||
|
||||
/* "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;
|
||||
|
||||
--animate-scroll-dot: scroll-dot 1.8s infinite;
|
||||
@keyframes scroll-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 9px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The original had four `max-width` media queries that 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. */
|
||||
@custom-variant tiny (@media (max-width: 480px));
|
||||
@custom-variant phone (@media (max-width: 520px));
|
||||
@custom-variant compact (@media (max-width: 680px));
|
||||
@custom-variant narrow (@media (max-width: 760px));
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 90px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body.is-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Preflight resets headings to `font-weight: inherit`; the original landing
|
||||
relied on the browser default, so restore it before anything else. */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Scroll-triggered entrance. A class rather than utilities because it is
|
||||
applied to ~30 elements and toggled from a shared IntersectionObserver. */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition:
|
||||
opacity 0.75s ease,
|
||||
transform 0.75s ease;
|
||||
}
|
||||
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Hero backdrop: two stacked gradients (vertical scrim + horizontal scrim)
|
||||
that utilities can only express as an unreadable arbitrary value. */
|
||||
.hero-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(3 20 34 / 0.36), rgb(3 20 34 / 0.2) 33%, rgb(3 20 34 / 0.92) 100%),
|
||||
linear-gradient(90deg, rgb(3 20 34 / 0.94) 0%, rgb(3 20 34 / 0.75) 48%, rgb(3 20 34 / 0.1) 100%);
|
||||
}
|
||||
|
||||
.econ-panel-loss {
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgb(240 101 107 / 0.16), transparent 30%),
|
||||
linear-gradient(145deg, var(--color-navy-deep), var(--color-navy));
|
||||
}
|
||||
|
||||
.econ-calc-surface {
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, rgb(0 196 180 / 0.25), transparent 34%),
|
||||
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cx(...values: (string | false | null | undefined)[]): string {
|
||||
return values.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
|
||||
|
||||
export const formatNumber = (value: number) => money.format(value)
|
||||
export const formatMoney = (value: number) => `${money.format(value)} ₽`
|
||||
|
||||
/** Mirrors the original calculator's tolerant number parsing. */
|
||||
export function parseAmount(raw: string): number {
|
||||
return Number(String(raw || '').replace(/\s/g, '').replace(',', '.')) || 0
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
|
||||
|
||||
const ENDPOINT = '/api/leads/fitness-centers'
|
||||
const DRAFT_KEY = 'exo_fitness_lead_draft'
|
||||
|
||||
/** 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
|
||||
: 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
|
||||
fields: body && !body.ok ? body.fields : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
window.dataLayer = window.dataLayer ?? []
|
||||
window.dataLayer.push({ event: 'fitness_lead_sent', form, configuration: values.configuration })
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
saveDraft(payload)
|
||||
console.warn('Lead endpoint error', error, payload)
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(payload: LeadInput) {
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
// Private-mode browsers throw on write; the visible error message is enough.
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
// Self-hosted so the landing stays self-contained and makes no third-party
|
||||
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
|
||||
// cyrillic are actually downloaded for this page.
|
||||
import '@fontsource-variable/inter'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (!container) throw new Error('#root is missing from index.html')
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
},
|
||||
"include": ["src", "shared"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.server.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo"
|
||||
},
|
||||
"include": ["server/src", "shared", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
// Set VITE_BASE_PATH=/fitness/ when the site is mounted on a sub-path, so
|
||||
// that 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||