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
+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>,
)