fitnes: apply the Fitness v2 design

Ports legacy/Fitnes v2 into the React landing.

- Copy: nav is now Экономика / Оборудование / Программы / Интерьер, every
  CTA reads «Получить консультацию», the hero has one button, a new label
  and new facts.
- Calculator: the average check defaults to 2 500 ₽, and a new investment
  field drives payback and 12-month ROI. Adds the ЭкзоКлиник source note
  and a new disclaimer.
- Constructor: «Состав оборудования», indications on the device cards. The
  CTA stays live with fewer than three devices picked, and the copy points
  to a consultation instead.
- «Юридическая рамка» is replaced by «Форматы размещения» (12 / 24 / 36 м²).
- Lead form: city and club format are dropped; company and email are
  optional. The server schema already treats them as optional.
- Section headings share one compact scale (headingScale + a `slim`
  640px variant), as in the design's v7 rule.

Deliberately kept from the current site rather than the mockup: the
+7 927 789-60-71 number and the callback modal behind the header call
button, both newer than the design.

With Inter substituted into the mockup (it ships no font and falls back to
Arial), every section height matches at 900 and 1440 px. At 375 px the
hero is 6 px shorter because of the existing fluid h1 on phones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-11 18:37:03 +06:00
co-authored by Claude Opus 5
parent 8af2e463b3
commit 432284eefb
16 changed files with 166 additions and 127 deletions
+18 -4
View File
@@ -113,7 +113,8 @@ src/
shared/lead.ts схема заявки (zod), общая для клиента и сервера shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM scripts/amo-check.ts диагностика подключения к amoCRM
legacy/index.html исходный однофайловый лендинг (визуальный эталон) legacy/index.html исходный однофайловый лендинг (первая версия дизайна)
legacy/Fitnes v2/ макет Fitness v2 — текущий визуальный эталон
``` ```
### Дизайн-система ### Дизайн-система
@@ -122,10 +123,17 @@ legacy/index.html исходный однофайловый лендинг (
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена `@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO. (`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, `lg` 1120, плюс Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, `lg` 1120, плюс
`max-width`-варианты `narrow` (760), `phone` (520), `compact` (680), `tiny` (480). `max-width`-варианты `narrow` (760), `slim` (640), `phone` (520), `compact` (680),
`tiny` (480).
Вёрстка совпадает с `legacy/index.html` попиксельно: высоты всех секций и общая Визуальный эталон — макет `legacy/Fitnes v2/`. Сам макет Inter не подключает и
высота страницы идентичны на 375 / 480 / 640 / 900 / 1120 / 1440 px. рисуется системным шрифтом, поэтому сверять его с сайтом нужно, подставив в него
Inter Variable. На одном шрифте высоты всех секций и общая высота страницы
совпадают на 900 и 1440 px; на 375 px hero ниже на 6 px, потому что на телефонах
h1 масштабируется (`clamp(34px, 11.3vw, 44px)`), а в макете стоит фиксированные 44 px.
Намеренные отличия от макета: телефон +7 927 789-60-71 (в макете старый номер),
кнопка «Звонок» в шапке открывает модальное окно «Перезвоним вам», а не `tel:`.
### Шрифты ### Шрифты
@@ -157,6 +165,12 @@ Inter подключён локально пакетом `@fontsource-variable/i
`оплаченные программы в день × средний чек × рабочие дни = выручка в месяц`. `оплаченные программы в день × средний чек × рабочие дни = выручка в месяц`.
Средний чек по умолчанию — 2 500 ₽ (данные «ЭкзоКлиник», Тольятти). Если указаны
инвестиции в запуск, считаются ещё два показателя, оба без операционных расходов:
* срок возврата = инвестиции ÷ выручка в месяц;
* ROI за 12 месяцев = (выручка в месяц × 12 − инвестиции) ÷ инвестиции.
--- ---
## Известные особенности ## Известные особенности
+9 -14
View File
@@ -43,9 +43,6 @@ export function ConstructorSection({
<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 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> <div>
<Label>Интерактивный подбор</Label> <Label>Интерактивный подбор</Label>
<p className="mt-[13px] text-[14px] text-mist">
Нажмите на аппараты справа изменится состав и логика зоны.
</p>
</div> </div>
<div role="group" aria-label="Количество аппаратов" className="grid max-w-[430px] grid-cols-[1fr_1fr] rounded-full bg-white/[0.08] p-[5px]"> <div role="group" aria-label="Количество аппаратов" className="grid max-w-[430px] grid-cols-[1fr_1fr] rounded-full bg-white/[0.08] p-[5px]">
@@ -71,7 +68,7 @@ export function ConstructorSection({
<div className="border-b border-white/10 p-[clamp(22px,4vw,40px)] md:border-r md:border-b-0"> <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 className="mb-[21px] flex items-end justify-between gap-[14px]">
<div> <div>
<h3 className="mb-[4px] text-[clamp(25px,3vw,36px)] text-white">Состав зоны</h3> <h3 className="mb-[4px] text-[clamp(25px,3vw,36px)] text-white">Состав оборудования</h3>
<p className="text-[13px] text-mist">{state.hint}</p> <p className="text-[13px] text-mist">{state.hint}</p>
</div> </div>
<div className="rounded-full border border-white/15 px-[11px] py-[8px] text-[12px] whitespace-nowrap text-[#BFD3DE]"> <div className="rounded-full border border-white/15 px-[11px] py-[8px] text-[12px] whitespace-nowrap text-[#BFD3DE]">
@@ -115,7 +112,12 @@ export function ConstructorSection({
className="mb-[5px] h-[84px] w-full object-contain drop-shadow-[0_12px_20px_rgb(0_0_0/0.24)] phone:h-[76px]" 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> <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> <small className="mt-[4px] block text-[10px] leading-[1.3] text-[#9DB4C1]">
{device.caption}
<span className="mt-[5px] block text-[9px] leading-[1.35] font-bold text-[#69E7DB]">
{device.indications}
</span>
</small>
</button> </button>
) )
})} })}
@@ -190,15 +192,8 @@ export function ConstructorSection({
</div> </div>
<div className="mt-auto flex flex-wrap gap-[10px] phone:[&>*]:w-full"> <div className="mt-auto flex flex-wrap gap-[10px] phone:[&>*]:w-full">
<ButtonLink <ButtonLink href="#request" onClick={onRequestConfiguration}>
href="#request" Получить консультацию
onClick={onRequestConfiguration}
style={{
opacity: state.isValid ? 1 : 0.45,
pointerEvents: state.isValid ? 'auto' : 'none',
}}
>
Получить эту конфигурацию
<ArrowRightIcon /> <ArrowRightIcon />
</ButtonLink> </ButtonLink>
<Button type="button" variant="outline" onClick={state.reset}> <Button type="button" variant="outline" onClick={state.reset}>
+52 -8
View File
@@ -1,9 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { econFormula, econGain, econLoss, econMetrics, econOps } from '../data/content' import { econFormula, econGain, econLoss, econMetrics, econOps } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { formatMoney, formatNumber, parseAmount } from '../lib/format' import { formatDecimal, formatMoney, formatNumber, parseAmount } from '../lib/format'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Container, Section } from './Layout' import { Container, Section, headingScale } from './Layout'
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]' 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]' const badge = 'grid size-[28px] place-items-center rounded-[10px] text-[11px]'
@@ -27,7 +27,7 @@ export function EconomicsSection() {
<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 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> </span>
<h2 className="text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy tiny:text-[34px]"> <h2 className={cx('max-w-[900px] text-navy', headingScale)}>
Клуб теряет деньги не на тренировке а между посещениями Клуб теряет деньги не на тренировке а между посещениями
</h2> </h2>
<p className="max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-[#60778B]"> <p className="max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-[#60778B]">
@@ -145,16 +145,28 @@ export function EconomicsSection() {
function RevenueCalculator() { function RevenueCalculator() {
const { ref, revealClass } = useReveal<HTMLDivElement>() const { ref, revealClass } = useReveal<HTMLDivElement>()
const [programs, setPrograms] = useState('') const [programs, setPrograms] = useState('')
const [check, setCheck] = useState('') // 2 500 ₽ is the ЭкзоКлиник average quoted in the note under the fields.
const [check, setCheck] = useState('2500')
const [days, setDays] = useState('30') const [days, setDays] = useState('30')
const [investment, setInvestment] = useState('')
const programsValue = parseAmount(programs) const programsValue = parseAmount(programs)
const checkValue = parseAmount(check) const checkValue = parseAmount(check)
const daysValue = parseAmount(days) const daysValue = parseAmount(days)
const investmentValue = parseAmount(investment)
const ready = programsValue > 0 && checkValue > 0 && daysValue > 0 const ready = programsValue > 0 && checkValue > 0 && daysValue > 0
const volume = programsValue * daysValue const volume = programsValue * daysValue
const month = volume * checkValue const month = volume * checkValue
// Payback and ROI need the investment on top of a ready revenue figure.
const hasInvestment = investmentValue > 0 && month > 0
const payback = hasInvestment
? `${formatDecimal(Math.round((investmentValue / month) * 10) / 10)} мес.`
: 'Укажите инвестиции'
const roi = hasInvestment
? `${formatNumber(Math.round(((month * 12 - investmentValue) / investmentValue) * 100))}%`
: 'Укажите инвестиции'
return ( return (
<div <div
ref={ref} ref={ref}
@@ -180,8 +192,8 @@ function RevenueCalculator() {
))} ))}
</div> </div>
<div className="grid gap-[13px] md:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] md:items-end"> <div className="grid gap-[13px] md:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] md:items-start">
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 tiny:grid-cols-[1fr]"> <div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 md:grid-cols-2 tiny:grid-cols-[1fr]">
<CalcField <CalcField
id="fitPrograms" id="fitPrograms"
label="Оплаченных программ восстановления в день" label="Оплаченных программ восстановления в день"
@@ -212,6 +224,16 @@ function RevenueCalculator() {
max={31} max={31}
step={1} step={1}
/> />
<CalcField
id="fitInvestment"
label="Инвестиции в запуск, ₽"
hint="Нужны только для расчёта ROI и срока возврата инвестиций"
value={investment}
onChange={setInvestment}
placeholder="ваша сумма"
min={0}
step={10000}
/>
</div> </div>
<div className="grid grid-cols-2 gap-[9px] md:self-stretch tiny:grid-cols-[1fr]"> <div className="grid grid-cols-2 gap-[9px] md:self-stretch tiny:grid-cols-[1fr]">
@@ -229,12 +251,34 @@ function RevenueCalculator() {
value={ready ? formatMoney(month * 12) : '—'} value={ready ? formatMoney(month * 12) : '—'}
note="до вычета расходов" note="до вычета расходов"
/> />
<CalcResult
label="Срок возврата инвестиций"
value={ready ? payback : '—'}
note="инвестиции ÷ дополнительная выручка в месяц"
/>
<CalcResult
label="ROI за 12 месяцев"
value={ready ? roi : '—'}
note="сценарный расчёт до операционных расходов"
/>
</div> </div>
</div> </div>
<div className="mt-[14px] rounded-[14px] border border-teal/25 bg-teal/[0.07] px-[15px] py-[13px] text-[10px] leading-[1.5] text-white/[0.72]">
<p>
Средний чек программы составляет <strong className="text-white">2 500 </strong>, по данным нашей клиники
«ЭкзоКлиник» в городе Тольятти за период 20252026гг.
</p>
<p className="mt-[5px]">
<strong className="text-white">
Средний чек программы может меняться в зависимости от региона и сезонных предложений.
</strong>
</p>
</div>
<p className="mt-[13px] text-[9px] leading-[1.45] text-white/[0.43]"> <p className="mt-[13px] text-[9px] leading-[1.45] text-white/[0.43]">
Расчёт показывает сценарную выручку, а не финансовую гарантию. Итоговая модель уточняется по конфигурации Расчёт показывает сценарную выручку и ROI, а не финансовую гарантию. ROI рассчитан без учёта ФОТ, налогов,
аппаратов, тарифам, ФОТ, загрузке и формату работы Recovery Zone. аренды, расходников и прочих операционных расходов. Финальная экономика уточняется после аудита.
</p> </p>
</div> </div>
) )
-18
View File
@@ -30,24 +30,6 @@ export function TextField({
) )
} }
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({ export function TextareaField({
id, id,
label, label,
+2 -9
View File
@@ -2,7 +2,6 @@ import heroBg from '../assets/images/hero-bg.webp'
import { heroFacts, heroSteps } from '../data/content' import { heroFacts, heroSteps } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { ButtonLink } from './Button' import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { Container, Label, LabelDot } from './Layout' import { Container, Label, LabelDot } from './Layout'
export function Hero() { export function Hero() {
@@ -27,7 +26,7 @@ export function Hero() {
<Container className="grid items-end gap-[34px] md:grid-cols-[minmax(0,1fr)_315px]"> <Container className="grid items-end gap-[34px] md:grid-cols-[minmax(0,1fr)_315px]">
<div ref={copy.ref} className={`max-w-[820px] ${copy.revealClass}`}> <div ref={copy.ref} className={`max-w-[820px] ${copy.revealClass}`}>
<Label>EXO Performance / Recovery Zone</Label> <Label>Зона восстановления и спортивной эффективности</Label>
<h1 className="my-[18px] mb-[24px] text-[clamp(46px,7.1vw,86px)] leading-[0.94] text-balance <h1 className="my-[18px] mb-[24px] text-[clamp(46px,7.1vw,86px)] leading-[0.94] text-balance
phone:text-[clamp(34px,11.3vw,44px)]"> phone:text-[clamp(34px,11.3vw,44px)]">
@@ -40,13 +39,7 @@ export function Hero() {
</p> </p>
<div className="flex flex-wrap gap-[12px] phone:[&>a]:w-full"> <div className="flex flex-wrap gap-[12px] phone:[&>a]:w-full">
<ButtonLink href="#constructor"> <ButtonLink href="#request">Получить консультацию</ButtonLink>
Собрать конфигурацию
<ArrowRightIcon />
</ButtonLink>
<ButtonLink href="#request" variant="ghost">
Получить предложение
</ButtonLink>
</div> </div>
<div className="mt-[34px] grid max-w-[790px] grid-cols-3 gap-[10px] phone:grid-cols-[1fr]"> <div className="mt-[34px] grid max-w-[790px] grid-cols-3 gap-[10px] phone:grid-cols-[1fr]">
+8 -3
View File
@@ -45,10 +45,15 @@ export function LabelDot() {
return <span className="size-[7px] rounded-full bg-teal shadow-[0_0_16px_rgb(0_196_180/0.75)]" /> return <span className="size-[7px] rounded-full bg-teal shadow-[0_0_16px_rgb(0_196_180/0.75)]" />
} }
/**
* Fitness v2's single compact scale for every section heading — the section
* titles, the economics heading and the request block all share it.
*/
export const headingScale =
'text-[clamp(22px,2.7vw,36px)] leading-[1.12] tracking-[-0.025em] slim:text-[clamp(21px,7vw,30px)] slim:leading-[1.16]'
export function SectionTitle({ className, children }: { className?: string; children: ReactNode }) { export function SectionTitle({ className, children }: { className?: string; children: ReactNode }) {
return ( return <h2 className={cx('mb-[18px] max-w-[920px]', headingScale, className)}>{children}</h2>
<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 }) { export function SectionLead({ tone = 'light', children }: { tone?: 'light' | 'dark'; children: ReactNode }) {
+7 -29
View File
@@ -1,9 +1,8 @@
import { useEffect, useRef, useState, type FormEvent } from 'react' import { useEffect, useRef, useState, type FormEvent } from 'react'
import { clubFormats } from '../data/content'
import { submitLead } from '../lib/lead' import { submitLead } from '../lib/lead'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Button } from './Button' import { Button } from './Button'
import { ConsentCheckbox, Honeypot, SelectField, TextField, TextareaField } from './FormField' import { ConsentCheckbox, Honeypot, TextField, TextareaField } from './FormField'
import { ArrowRightIcon } from './Icons' import { ArrowRightIcon } from './Icons'
const EMPTY = { const EMPTY = {
@@ -11,8 +10,6 @@ const EMPTY = {
company: '', company: '',
phone: '', phone: '',
email: '', email: '',
city: '',
club_format: clubFormats[0],
comment: '', comment: '',
} }
@@ -26,7 +23,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' }) const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
// The constructor's "получить эту конфигурацию" CTA writes into the comment. // The constructor's "получить консультацию" CTA writes into the comment.
useEffect(() => { useEffect(() => {
if (commentPrefill) setValues((current) => ({ ...current, comment: commentPrefill })) if (commentPrefill) setValues((current) => ({ ...current, comment: commentPrefill }))
}, [commentPrefill]) }, [commentPrefill])
@@ -71,7 +68,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
onSubmit={onSubmit} onSubmit={onSubmit}
className="bg-white p-[clamp(24px,4vw,42px)] text-ink" className="bg-white p-[clamp(24px,4vw,42px)] text-ink"
> >
<h3 className="mb-[8px] text-[28px]">Получить конфигурацию</h3> <h3 className="mb-[8px] text-[28px]">Получить консультацию</h3>
<p className="mb-[23px] text-[13px] text-muted"> <p className="mb-[23px] text-[13px] text-muted">
Выбранный в конструкторе состав автоматически добавится в заявку. Выбранный в конструкторе состав автоматически добавится в заявку.
</p> </p>
@@ -89,11 +86,10 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
/> />
<TextField <TextField
id="company" id="company"
label="Фитнес-клуб / сеть *" label="Фитнес-клуб / сеть"
name="company" name="company"
autoComplete="organization" autoComplete="organization"
placeholder="Название клуба" placeholder="Название клуба"
required
value={values.company} value={values.company}
onChange={set('company')} onChange={set('company')}
/> />
@@ -110,38 +106,20 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
/> />
<TextField <TextField
id="email" id="email"
label="Email *" label="Email"
name="email" name="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
placeholder="name@company.ru" placeholder="name@company.ru"
required
value={values.email} value={values.email}
onChange={set('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 <TextareaField
id="comment" id="comment"
label="Комментарий" label="Комментарий"
name="comment" name="comment"
full full
placeholder="Площадь, поток, задачи клуба, наличие медицинской лицензии" placeholder="Площадь, поток, задачи клуба, желаемый формат запуска"
value={values.comment} value={values.comment}
onChange={set('comment')} onChange={set('comment')}
/> />
@@ -152,7 +130,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
<ConsentCheckbox checked={consent} onChange={setConsent} /> <ConsentCheckbox checked={consent} onChange={setConsent} />
<Button type="submit" className="w-full" disabled={pending}> <Button type="submit" className="w-full" disabled={pending}>
{pending ? 'Отправляем…' : 'Получить предложение'} {pending ? 'Отправляем…' : 'Получить консультацию'}
{pending ? null : <ArrowRightIcon />} {pending ? null : <ArrowRightIcon />}
</Button> </Button>
+1 -1
View File
@@ -12,7 +12,7 @@ export function MobileCta({ show, onCallbackClick }: { show: boolean; onCallback
)} )}
> >
<ButtonLink href="#request" size="min-h-[47px] px-[22px]" className="w-full text-[13px]"> <ButtonLink href="#request" size="min-h-[47px] px-[22px]" className="w-full text-[13px]">
Получить конфигурацию Получить консультацию
</ButtonLink> </ButtonLink>
<button <button
type="button" type="button"
+2 -2
View File
@@ -2,7 +2,7 @@ import { ctaChecklist } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { CheckIcon } from './Icons' import { CheckIcon } from './Icons'
import { Container, Label, Section } from './Layout' import { Container, Label, Section, headingScale } from './Layout'
import { LeadForm } from './LeadForm' import { LeadForm } from './LeadForm'
export function RequestSection({ configuration, commentPrefill }: { configuration: string; commentPrefill: string }) { export function RequestSection({ configuration, commentPrefill }: { configuration: string; commentPrefill: string }) {
@@ -26,7 +26,7 @@ export function RequestSection({ configuration, commentPrefill }: { configuratio
<div className="grid md:grid-cols-[1.03fr_0.97fr]"> <div className="grid md:grid-cols-[1.03fr_0.97fr]">
<div className="p-[clamp(28px,5vw,58px)]"> <div className="p-[clamp(28px,5vw,58px)]">
<Label>Персональная конфигурация</Label> <Label>Персональная конфигурация</Label>
<h2 className="mb-[20px] max-w-[680px] text-[clamp(36px,5.5vw,66px)] leading-none"> <h2 className={cx('mb-[20px] max-w-[680px]', headingScale)}>
Подберем индивидуальный комплект оборудования точно под формат вашего клуба. Подберем индивидуальный комплект оборудования точно под формат вашего клуба.
</h2> </h2>
<p className="max-w-[610px] text-[16px] text-[#B6C9D4]"> <p className="max-w-[610px] text-[16px] text-[#B6C9D4]">
+1 -1
View File
@@ -54,7 +54,7 @@ export function SiteHeader({ scrolled, onCallbackClick }: { scrolled: boolean; o
size="min-h-[42px] px-[16px] narrow:px-[13px] phone:min-h-[40px] phone:px-[11px]" size="min-h-[42px] px-[16px] narrow:px-[13px] phone:min-h-[40px] phone:px-[11px]"
className="text-[13px] whitespace-nowrap phone:text-[11px]" className="text-[13px] whitespace-nowrap phone:text-[11px]"
> >
Получить конфигурацию Получить консультацию
</ButtonLink> </ButtonLink>
</div> </div>
</Container> </Container>
+11 -7
View File
@@ -1,11 +1,11 @@
import { ecosystem, legalOptions, proofStats } from '../data/content' import { ecosystem, placementOptions, proofStats } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionLead, SectionTitle } from './Layout' import { Container, Eyebrow, Section, SectionLead, SectionTitle } from './Layout'
export function WhyExoSection() { export function WhyExoSection() {
const copy = useReveal<HTMLDivElement>() const copy = useReveal<HTMLDivElement>()
const legal = useReveal<HTMLElement>() const placement = useReveal<HTMLElement>()
return ( return (
<Section id="why-exo" tone="dark"> <Section id="why-exo" tone="dark">
@@ -40,12 +40,16 @@ export function WhyExoSection() {
</div> </div>
<aside <aside
ref={legal.ref} ref={placement.ref}
className={cx('rounded-[28px] bg-white p-[clamp(23px,4vw,36px)] text-ink', legal.revealClass)} className={cx('rounded-[28px] bg-white p-[clamp(23px,4vw,36px)] text-ink', placement.revealClass)}
> >
<Eyebrow>Юридическая рамка</Eyebrow> <Eyebrow>Форматы размещения</Eyebrow>
<h3 className="mb-[18px] text-[27px]">Сценарий фиксируется до запуска</h3> <h3 className="mb-[12px] text-[27px]">12 м², которые работают на выручку клуба</h3>
{legalOptions.map((option, index) => ( <p className="mb-[18px] text-[13px] leading-[1.55] text-muted">
Работу сервиса может сопровождать обученный сотрудник клуба тренер, администратор или оператор. Формат
выбирается по площади, потоку и желаемой пропускной способности.
</p>
{placementOptions.map((option, index) => (
<div <div
key={option.title} key={option.title}
className={cx('py-[16px]', index === 0 ? 'pt-0' : 'border-t border-navy/[0.12]')} className={cx('py-[16px]', index === 0 ? 'pt-0' : 'border-t border-navy/[0.12]')}
+14 -16
View File
@@ -12,15 +12,15 @@ export const contacts = {
} }
export const navLinks = [ export const navLinks = [
{ href: '#market', label: 'Рынок' }, { href: '#fitness-economics', label: 'Экономика' },
{ href: '#constructor', label: 'Конструктор' }, { href: '#constructor', label: 'Оборудование' },
{ href: '#formats', label: 'Визуализации' }, { href: '#programs', label: 'Программы' },
{ href: '#why-exo', label: 'Экосистема' }, { href: '#formats', label: 'Интерьер' },
] ]
export const heroFacts = [ export const heroFacts = [
{ title: '3 аппарата', text: омпактное ядро под задачи клуба' }, { title: '34 из 100', text: лиентов клуб не удерживает за год при среднем показателе 66%' },
{ title: '5 аппаратов', text: 'полный маршрут recovery и спорта' }, { title: '4 источника выручки', text: 'сессии, пакеты, premium-сервис и персональные тренировки' },
{ {
title: 'Окупаемость', title: 'Окупаемость',
emphasis: 'от 6 месяцев', emphasis: 'от 6 месяцев',
@@ -244,26 +244,24 @@ export const ecosystem = [
{ title: 'Масштаб', text: 'типовой формат для сети' }, { title: 'Масштаб', text: 'типовой формат для сети' },
] ]
export const legalOptions = [ export const placementOptions = [
{ {
title: 'Медицинская Recovery Zone', title: '1 кабинет — 12 м²',
text: 'Врач или медицинский специалист, допуск, назначение, протокол, противопоказания, санитарный регламент и лицензируемая деятельность.', text: 'Компактный старт для одного потока клиентов и базового набора программ восстановления.',
}, },
{ {
title: 'Wellness-only', title: '2 кабинета — 24 м²',
text: 'Отдельная модель без диагнозов, лечебных обещаний и медицинских показаний — recovery, расслабление и забота о самочувствии.', text: 'Два параллельных потока: активные и более автономные программы можно разделить по кабинетам.',
}, },
{ {
title: 'Планировка', title: '3 кабинета — 36 м²',
text: 'Для лицензируемого медицинского формата в материалах EXO предусмотрены решения от 35 м² для двух кабинетов и от 45 м² для трёх.', text: 'Расширенный формат для высокой загрузки клуба и одновременной работы нескольких программ.',
}, },
] ]
export const ctaChecklist = [ export const ctaChecklist = [
'состав аппаратов и роли каждого в зоне', 'состав оборудования и роль каждого аппарата в сервисе восстановления',
'вариант планировки и внешний вид в интерьере клуба', 'вариант планировки и внешний вид в интерьере клуба',
'меню программ, обучение и сценарии рекомендаций', 'меню программ, обучение и сценарии рекомендаций',
'план запуска и контрольные показатели пилота', 'план запуска и контрольные показатели пилота',
] ]
export const clubFormats = ['Один клуб', 'Сеть клубов', 'Premium-клуб', 'Студия / performance', 'Пока не определён']
+12 -5
View File
@@ -11,6 +11,8 @@ export interface Device {
role: string role: string
benefits: string[] benefits: string[]
caption: string caption: string
/** Short "what it works on" line under the caption on the picker card. */
indications: string
image: string image: string
} }
@@ -20,35 +22,40 @@ export const devices = {
name: 'ЭкзоТерапия', name: 'ЭкзоТерапия',
role: 'флагманский full-body recovery и premium-продукт', role: 'флагманский full-body recovery и premium-продукт',
benefits: ['заметный якорь зоны', 'восстановительный этап после нагрузки', 'повышает технологичность сервиса'], benefits: ['заметный якорь зоны', 'восстановительный этап после нагрузки', 'повышает технологичность сервиса'],
caption: 'Флагманский full-body recovery', caption: 'Full-body recovery',
indications: 'ОДА • неврология • восстановление после нагрузки',
image: deviceTherapy, image: deviceTherapy,
}, },
magnet: { magnet: {
name: 'ЭкзоМагнит 3 в 1', name: 'ЭкзоМагнит 3 в 1',
role: 'medical-core для нейромышечных и опорно-двигательных маршрутов', role: 'medical-core для нейромышечных и опорно-двигательных маршрутов',
benefits: ['магнит + УВТ + инфракрасный модуль', 'работа по медицинскому протоколу', 'ядро для спортивной медицины'], benefits: ['магнит + УВТ + инфракрасный модуль', 'работа по медицинскому протоколу', 'ядро для спортивной медицины'],
caption: 'Нейромышечное и medical-core ядро', caption: 'Нейромышечное ядро',
indications: 'суставы • позвоночник • мышцы • сухожилия',
image: deviceMagnet, image: deviceMagnet,
}, },
tecar: { tecar: {
name: 'ЭкзоТекар 3 в 1', name: 'ЭкзоТекар 3 в 1',
role: 'активная локальная работа с тканями, фасциями и подвижностью', role: 'активная локальная работа с тканями, фасциями и подвижностью',
benefits: ['TECAR + УВТ + ультразвук', 'инструмент специалиста', 'сильная связь с PT и return-to-sport'], benefits: ['TECAR + УВТ + ультразвук', 'инструмент специалиста', 'сильная связь с PT и return-to-sport'],
caption: 'Ткани, фасции, локальная работа', caption: 'Локальная работа с тканями',
indications: 'мышцы • связки • сухожилия • фасции',
image: deviceTecar, image: deviceTecar,
}, },
press: { press: {
name: 'ЭкзоПресс', name: 'ЭкзоПресс',
role: 'потоковый recovery для ног после бега, leg day и нагрузки', role: 'потоковый recovery для ног после бега, leg day и нагрузки',
benefits: ['компрессия + тепло / холод', 'понятный сервис для массового спроса', 'легко включается в пакеты'], benefits: ['компрессия + тепло / холод', 'понятный сервис для массового спроса', 'легко включается в пакеты'],
caption: 'Ноги, компрессия, потоковый формат', caption: 'Потоковое восстановление ног',
indications: 'отёки • лимфоотток • тяжесть в ногах',
image: devicePress, image: devicePress,
}, },
hydrogen: { hydrogen: {
name: 'ЭкзоВодород', name: 'ЭкзоВодород',
role: 'автономная lounge-процедура для recovery и recharge', role: 'автономная lounge-процедура для recovery и recharge',
benefits: ['спокойный формат в кресле', 'минимальное участие оператора', 'отдельная причина прийти в recovery-day'], benefits: ['спокойный формат в кресле', 'минимальное участие оператора', 'отдельная причина прийти в recovery-day'],
caption: 'Lounge recovery и перезагрузка', caption: 'Lounge recovery',
indications: 'системное восстановление • антистресс • метаболизм',
image: deviceHydrogen, image: deviceHydrogen,
}, },
} satisfies Record<string, Device> } satisfies Record<string, Device>
+15 -4
View File
@@ -66,6 +66,10 @@ export function useConstructor() {
} }
} }
// Fewer than three picked is no longer a dead end: the CTA stays live and
// the copy steers the visitor to a consultation instead.
const incomplete = mode === 3 && selected.length < MAX_IN_THREE_MODE
return { return {
mode, mode,
selected, selected,
@@ -76,7 +80,6 @@ export function useConstructor() {
applyPreset, applyPreset,
reset, reset,
benefits: benefits.slice(0, 4), benefits: benefits.slice(0, 4),
isValid: mode === 5 || selected.length === MAX_IN_THREE_MODE,
hint: hint:
mode === 5 mode === 5
? 'Полная линейка выбрана' ? 'Полная линейка выбрана'
@@ -88,19 +91,27 @@ export function useConstructor() {
title: title:
mode === 5 mode === 5
? 'Полная зона — 5 аппаратов' ? 'Полная зона — 5 аппаратов'
: incomplete
? `Выбрано ${selected.length} из ${MAX_IN_THREE_MODE} аппаратов`
: matchedPreset : matchedPreset
? `${matchedPreset[1].title} — 3 аппарата` ? `${matchedPreset[1].title} — 3 аппарата`
: 'Персональный микс — 3 аппарата', : 'Персональный микс — 3 аппарата',
subtitle: subtitle:
mode === 5 mode === 5
? 'Полный маршрут: active medical-core, premium recovery, потоковые и автономные процедуры.' ? 'Полный маршрут: active medical-core, premium recovery, потоковые и автономные процедуры.'
: incomplete
? 'Можно продолжить подбор или получить консультацию — специалист поможет собрать оптимальную конфигурацию.'
: matchedPreset : matchedPreset
? matchedPreset[1].subtitle ? matchedPreset[1].subtitle
: 'Состав собран под выбранные вами приоритеты. Итоговая логика уточняется после аудита клуба.', : 'Состав собран под выбранные вами приоритеты. Итоговая логика уточняется после аудита клуба.',
/** Hidden form field sent to amoCRM. */ /** Hidden form field sent to amoCRM. */
configurationValue: `${mode} аппарата: ${configString}`, configurationValue: selected.length
/** Pre-filled into the comment box by the "get this configuration" CTA. */ ? `${selected.length} аппарата: ${configString}`
requestComment: `Интересует конфигурация: ${mode} аппарата${configString}`, : 'Конфигурация не выбрананужна консультация',
/** Pre-filled into the comment box by the constructor's CTA. */
requestComment: selected.length
? `Интересует конфигурация: ${selected.length} аппарата — ${configString}`
: 'Нужна консультация по подбору конфигурации Recovery Zone',
} }
}, [mode, selected, preset, toggleDevice, setMode, applyPreset, reset]) }, [mode, selected, preset, toggleDevice, setMode, applyPreset, reset])
} }
+3
View File
@@ -86,6 +86,9 @@
@custom-variant phone (@media (max-width: 520px)); @custom-variant phone (@media (max-width: 520px));
@custom-variant compact (@media (max-width: 680px)); @custom-variant compact (@media (max-width: 680px));
@custom-variant narrow (@media (max-width: 760px)); @custom-variant narrow (@media (max-width: 760px));
/* Fitness v2 steps the section headings down at this width. Note it overlaps
`sm` at exactly 640px, as the design's `max-width: 640px` query does. */
@custom-variant slim (@media (max-width: 640px));
@layer base { @layer base {
html { html {
+5
View File
@@ -3,6 +3,11 @@ const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
export const formatNumber = (value: number) => money.format(value) export const formatNumber = (value: number) => money.format(value)
export const formatMoney = (value: number) => `${money.format(value)}` export const formatMoney = (value: number) => `${money.format(value)}`
const decimal = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 })
/** One decimal place at most, e.g. the payback period in months ("7,5"). */
export const formatDecimal = (value: number) => decimal.format(value)
/** Mirrors the original calculator's tolerant number parsing. */ /** Mirrors the original calculator's tolerant number parsing. */
export function parseAmount(raw: string): number { export function parseAmount(raw: string): number {
return Number(String(raw || '').replace(/\s/g, '').replace(',', '.')) || 0 return Number(String(raw || '').replace(/\s/g, '').replace(',', '.')) || 0