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>
This commit is contained in:
Yuriy Panov
2026-08-28 13:21:48 +06:00
co-authored by Claude Opus 5
commit 60de4ef27c
69 changed files with 8625 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "fitnes-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:client"],
"port": 5173
}
]
}
+33
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.local
.DS_Store
+151
View File
@@ -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` перед сборкой.
+44
View File
@@ -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>
File diff suppressed because one or more lines are too long
+3715
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><rect width='128' height='128' rx='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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+68
View File
@@ -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.')
+247
View File
@@ -0,0 +1,247 @@
import type { AmoConfig } from './config.ts'
/* -------------------------------------------------------------------------- */
/* amoCRM REST types (only the parts this integration touches) */
/* -------------------------------------------------------------------------- */
export interface AmoEnum {
id: number
value: string
enum_code?: string | null
}
export interface AmoCustomField {
id: number
name: string
code: string | null
type: string
enums?: AmoEnum[] | null
}
interface AmoFieldValue {
value: string | number | boolean | null
enum_id?: number
enum_code?: string
}
export interface AmoFieldEntry {
field_id?: number
field_code?: string
values: AmoFieldValue[]
}
export interface AmoContact {
id: number
name?: string
custom_fields_values?: AmoFieldEntry[] | null
}
export interface AmoLead {
id: number
}
export class AmoError extends Error {
constructor(
message: string,
readonly status?: number,
readonly detail?: unknown,
) {
super(message)
this.name = 'AmoError'
}
}
/* -------------------------------------------------------------------------- */
/* Client */
/* -------------------------------------------------------------------------- */
const RETRYABLE = new Set([429, 500, 502, 503, 504])
export class AmoClient {
private leadFields?: Promise<AmoCustomField[]>
private contactFields?: Promise<AmoCustomField[]>
constructor(private readonly config: AmoConfig) {}
get baseUrl(): string {
return this.config.baseUrl
}
/**
* A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses
* for "found nothing" on every collection endpoint.
*/
private async request<T>(
path: string,
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
attempt = 1,
): Promise<T | null> {
const url = new URL(path, this.config.baseUrl)
for (const [key, value] of Object.entries(init.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
let response: Response
try {
response = await fetch(url, {
method: init.method ?? 'GET',
headers: {
Authorization: `Bearer ${this.config.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
signal: AbortSignal.timeout(15_000),
})
} catch (cause) {
if (attempt < 3) {
await sleep(attempt * 400)
return this.request<T>(path, init, attempt + 1)
}
throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`)
}
if (response.status === 204) return null
if (!response.ok) {
const detail = await response.text().catch(() => '')
if (RETRYABLE.has(response.status) && attempt < 3) {
await sleep(attempt * 700)
return this.request<T>(path, init, attempt + 1)
}
if (response.status === 401) {
throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail)
}
throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail)
}
return (await response.json()) as T
}
/* ---------------------------------------------------------------------- */
/* Field metadata — fetched once per process, then reused */
/* ---------------------------------------------------------------------- */
private async fetchFields(entity: 'leads' | 'contacts'): Promise<AmoCustomField[]> {
const collected: AmoCustomField[] = []
for (let page = 1; page <= 10; page++) {
const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>(
`/api/v4/${entity}/custom_fields`,
{ query: { page, limit: 250 } },
)
const batch = res?._embedded?.custom_fields ?? []
collected.push(...batch)
if (batch.length < 250) break
}
return collected
}
getLeadFields(): Promise<AmoCustomField[]> {
this.leadFields ??= this.fetchFields('leads')
return this.leadFields
}
getContactFields(): Promise<AmoCustomField[]> {
this.contactFields ??= this.fetchFields('contacts')
return this.contactFields
}
/* ---------------------------------------------------------------------- */
/* Contacts */
/* ---------------------------------------------------------------------- */
/** Full-text search. amoCRM matches phones and emails regardless of format. */
async findContact(queries: string[]): Promise<AmoContact | null> {
for (const query of queries) {
if (!query) continue
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
query: { query, limit: 1, with: 'leads' },
})
const contact = res?._embedded?.contacts?.[0]
if (contact) return contact
}
return null
}
async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise<AmoContact> {
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
method: 'POST',
body: [
{
name: input.name,
responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined,
},
],
})
const contact = res?._embedded?.contacts?.[0]
if (!contact) throw new AmoError('amoCRM did not return the created contact')
return contact
}
/** Adds phone/email to an existing contact without dropping what is there. */
async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise<void> {
if (!fields.length) return
await this.request(`/api/v4/contacts/${contactId}`, {
method: 'PATCH',
body: { custom_fields_values: fields },
})
}
/* ---------------------------------------------------------------------- */
/* Leads */
/* ---------------------------------------------------------------------- */
/**
* Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want.
*/
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] }
const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name }))
const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', {
method: 'POST',
body: [
{
name: input.name,
pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded,
},
],
})
const lead = res?._embedded?.leads?.[0]
if (!lead) throw new AmoError('amoCRM did not return the created lead')
return lead
}
async addLeadNote(leadId: number, text: string): Promise<void> {
await this.request(`/api/v4/leads/${leadId}/notes`, {
method: 'POST',
body: [{ note_type: 'common', params: { text } }],
})
}
/* ---------------------------------------------------------------------- */
/* Diagnostics */
/* ---------------------------------------------------------------------- */
async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> {
return this.request('/api/v4/account')
}
async getPipeline(id: number): Promise<{
id: number
name: string
_embedded?: { statuses?: { id: number; name: string; sort: number }[] }
} | null> {
return this.request(`/api/v4/leads/pipelines/${id}`)
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+58
View File
@@ -0,0 +1,58 @@
import { config as loadEnv } from 'dotenv'
loadEnv()
function optional(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function optionalNumber(name: string): number | undefined {
const raw = optional(name)
if (raw === undefined) return undefined
const value = Number(raw)
if (!Number.isFinite(value)) throw new Error(`${name} must be a number, got "${raw}"`)
return value
}
/**
* amoCRM settings. Deliberately not throwing when they are missing: the site
* must still boot and serve pages without a CRM connection — only the lead
* endpoint degrades, and it says so explicitly.
*/
export interface AmoConfig {
baseUrl: string
token: string
pipelineId: number
responsibleUserId?: number
tags: string[]
}
export function readAmoConfig(): AmoConfig | null {
const subdomain = optional('AMO_SUBDOMAIN')
const token = optional('AMO_LONG_LIVED_TOKEN')
if (!subdomain || !token) return null
// Accepts "exotherapy", "exotherapy.amocrm.ru", or a full URL (an explicit
// scheme is kept as-is, which is what local mocks and self-hosted setups need).
const host = subdomain.replace(/\/+$/, '')
const baseUrl = /^https?:\/\//.test(host)
? host
: `https://${host.includes('.') ? host : `${host}.${optional('AMO_DOMAIN') ?? 'amocrm.ru'}`}`
return {
baseUrl,
token,
pipelineId: optionalNumber('AMO_PIPELINE_ID') ?? 10980758,
responsibleUserId: optionalNumber('AMO_RESPONSIBLE_USER_ID'),
tags: (optional('AMO_LEAD_TAGS') ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}
}
export const serverConfig = {
port: optionalNumber('PORT') ?? 3000,
isProduction: process.env.NODE_ENV === 'production',
}
+112
View File
@@ -0,0 +1,112 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { leadSchema, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts'
import { createRateLimiter } from './rate-limit.ts'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const clientDir = path.join(rootDir, 'dist/client')
const amoConfig = readAmoConfig()
const leadService = amoConfig ? new LeadService(new AmoClient(amoConfig)) : null
if (!leadService) {
console.warn(
'[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN are not set — the site runs, but /api/leads/* will return 503.',
)
}
const app = express()
app.set('trust proxy', true)
app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
})
app.post('/api/leads/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)',
)
})
+153
View File
@@ -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}`
}
+124
View File
@@ -0,0 +1,124 @@
import type { LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '')
/**
* Last ten digits — the part that is stable across "+7 (999) 123-45-67",
* "8 999 1234567" and "9991234567". Used to decide whether amoCRM already
* knows a phone number.
*/
const phoneKey = (value: string) => digitsOnly(value).slice(-10)
/**
* amoCRM's full-text search matches digit substrings, but a contact stored as
* "+7 999…" is not found by a query starting with "8 999…". Try every spelling
* a Russian visitor might type.
*/
function phoneQueries(phone: string): string[] {
const digits = digitsOnly(phone)
const queries = new Set<string>([phone.trim(), digits])
if (digits.length >= 10) {
const national = digits.slice(-10)
queries.add(national)
queries.add(`7${national}`)
queries.add(`8${national}`)
}
return [...queries].filter(Boolean)
}
/** Reads the values already stored in a contact's standard multitext field. */
function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] {
const entry = contact.custom_fields_values?.find((f) => f.field_code === code)
return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean)
}
function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] {
const entries: AmoFieldEntry[] = [
{ field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] },
]
if (payload.email) {
entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] })
}
return entries
}
export interface CreatedLead {
leadId: number
contactId: number
contactCreated: boolean
}
export class LeadService {
private fieldMap?: Promise<LeadFieldMap>
constructor(private readonly amo: AmoClient) {}
private getFieldMap(): Promise<LeadFieldMap> {
this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
return this.fieldMap
}
async submit(payload: LeadPayload): Promise<CreatedLead> {
const fieldMap = await this.getFieldMap()
// 1. Reuse the existing contact when the phone or email is already known.
const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? ''])
let contactId: number
let contactCreated = false
if (existing) {
contactId = existing.id
// Add whichever channel amoCRM does not have on file yet.
const missing: AmoFieldEntry[] = []
const knownPhones = existingValues(existing, 'PHONE').map(phoneKey)
if (!knownPhones.includes(phoneKey(payload.phone))) {
missing.push({
field_code: 'PHONE',
values: [
...existingValues(existing, 'PHONE').map((value) => ({ value })),
{ value: payload.phone, enum_code: 'WORK' },
],
})
}
if (payload.email) {
const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase())
if (!knownEmails.includes(payload.email.toLowerCase())) {
missing.push({
field_code: 'EMAIL',
values: [
...existingValues(existing, 'EMAIL').map((value) => ({ value })),
{ value: payload.email, enum_code: 'WORK' },
],
})
}
}
await this.amo.appendContactFields(contactId, missing)
} else {
const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) })
contactId = created.id
contactCreated = true
}
// 2. Lead in the configured pipeline, first stage.
const { fields, note } = mapLead(payload, fieldMap)
const lead = await this.amo.createLead({
name: buildLeadName(payload),
contactId,
fields,
tags: [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 }
}
}
+33
View File
@@ -0,0 +1,33 @@
interface Bucket {
count: number
resetAt: number
}
/**
* Minimal fixed-window limiter for the public lead endpoint. In-memory on
* purpose: a single landing page behind one Node process does not warrant a
* shared store, and losing the counters on restart is harmless.
*/
export function createRateLimiter(options: { limit: number; windowMs: number }) {
const buckets = new Map<string, Bucket>()
return function check(key: string): { allowed: boolean; retryAfterSeconds: number } {
const now = Date.now()
if (buckets.size > 5000) {
for (const [k, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(k)
}
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + options.windowMs })
return { allowed: true, retryAfterSeconds: 0 }
}
bucket.count += 1
return {
allowed: bucket.count <= options.limit,
retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000),
}
}
}
+44
View File
@@ -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> }
+56
View File
@@ -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} />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+63
View File
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+147
View File
@@ -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 }
}
+333
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+40
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+115
View File
@@ -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>
)
}
+172
View File
@@ -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>
)
}
+100
View File
@@ -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>
)
}
+27
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+55
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+269
View File
@@ -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', 'Пока не определён']
+89
View File
@@ -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: 'Полная зона восстановления с пятью аппаратами' },
}
+10
View File
@@ -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])
}
+106
View File
@@ -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])
}
+12
View File
@@ -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])
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react'
/**
* Scroll-in animation, matching the original page: fires once at 12% visibility
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
* that `prefers-reduced-motion` can neutralise it in one place.
*/
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element || visible) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
setVisible(true)
observer.unobserve(entry.target)
}
},
{ threshold: 0.12 },
)
observer.observe(element)
return () => observer.disconnect()
}, [visible])
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
}
+26
View File
@@ -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
}
+198
View File
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cx(...values: (string | false | null | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
+9
View File
@@ -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
}
+81
View File
@@ -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>[]
}
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Self-hosted so the landing stays self-contained and makes no third-party
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
// cyrillic are actually downloaded for this page.
import '@fontsource-variable/inter'
import App from './App'
import './index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root is missing from index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src", "shared"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo"
},
"include": ["server/src", "shared", "scripts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
// Set VITE_BASE_PATH=/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 },
},
},
})