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:
co-authored by
Claude Opus 5
parent
8af2e463b3
commit
432284eefb
+18
-4
@@ -113,7 +113,8 @@ src/
|
||||
shared/lead.ts схема заявки (zod), общая для клиента и сервера
|
||||
server/src/ Express API + клиент 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 по умолчанию отключена
|
||||
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
|
||||
Брейкпоинты соответствуют исходной вёрстке: `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` попиксельно: высоты всех секций и общая
|
||||
высота страницы идентичны на 375 / 480 / 640 / 900 / 1120 / 1440 px.
|
||||
Визуальный эталон — макет `legacy/Fitnes v2/`. Сам макет Inter не подключает и
|
||||
рисуется системным шрифтом, поэтому сверять его с сайтом нужно, подставив в него
|
||||
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 − инвестиции) ÷ инвестиции.
|
||||
|
||||
---
|
||||
|
||||
## Известные особенности
|
||||
|
||||
@@ -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>
|
||||
<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]">
|
||||
@@ -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="mb-[21px] flex items-end justify-between gap-[14px]">
|
||||
<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>
|
||||
</div>
|
||||
<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]"
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
@@ -190,15 +192,8 @@ export function ConstructorSection({
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
Получить эту конфигурацию
|
||||
<ButtonLink href="#request" onClick={onRequestConfiguration}>
|
||||
Получить консультацию
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<Button type="button" variant="outline" onClick={state.reset}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { formatDecimal, formatMoney, formatNumber, parseAmount } from '../lib/format'
|
||||
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 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>
|
||||
<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>
|
||||
<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() {
|
||||
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||
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 [investment, setInvestment] = useState('')
|
||||
|
||||
const programsValue = parseAmount(programs)
|
||||
const checkValue = parseAmount(check)
|
||||
const daysValue = parseAmount(days)
|
||||
const investmentValue = parseAmount(investment)
|
||||
const ready = programsValue > 0 && checkValue > 0 && daysValue > 0
|
||||
const volume = programsValue * daysValue
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -180,8 +192,8 @@ function RevenueCalculator() {
|
||||
))}
|
||||
</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]">
|
||||
<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 md:grid-cols-2 tiny:grid-cols-[1fr]">
|
||||
<CalcField
|
||||
id="fitPrograms"
|
||||
label="Оплаченных программ восстановления в день"
|
||||
@@ -212,6 +224,16 @@ function RevenueCalculator() {
|
||||
max={31}
|
||||
step={1}
|
||||
/>
|
||||
<CalcField
|
||||
id="fitInvestment"
|
||||
label="Инвестиции в запуск, ₽"
|
||||
hint="Нужны только для расчёта ROI и срока возврата инвестиций"
|
||||
value={investment}
|
||||
onChange={setInvestment}
|
||||
placeholder="ваша сумма"
|
||||
min={0}
|
||||
step={10000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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) : '—'}
|
||||
note="до вычета расходов"
|
||||
/>
|
||||
<CalcResult
|
||||
label="Срок возврата инвестиций"
|
||||
value={ready ? payback : '—'}
|
||||
note="инвестиции ÷ дополнительная выручка в месяц"
|
||||
/>
|
||||
<CalcResult
|
||||
label="ROI за 12 месяцев"
|
||||
value={ready ? roi : '—'}
|
||||
note="сценарный расчёт до операционных расходов"
|
||||
/>
|
||||
</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>, по данным нашей клиники
|
||||
«ЭкзоКлиник» в городе Тольятти за период 2025–2026гг.
|
||||
</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]">
|
||||
Расчёт показывает сценарную выручку, а не финансовую гарантию. Итоговая модель уточняется по конфигурации
|
||||
аппаратов, тарифам, ФОТ, загрузке и формату работы Recovery Zone.
|
||||
Расчёт показывает сценарную выручку и ROI, а не финансовую гарантию. ROI рассчитан без учёта ФОТ, налогов,
|
||||
аренды, расходников и прочих операционных расходов. Финальная экономика уточняется после аудита.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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() {
|
||||
@@ -27,7 +26,7 @@ export function Hero() {
|
||||
|
||||
<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>
|
||||
<Label>Зона восстановления и спортивной эффективности</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)]">
|
||||
@@ -40,13 +39,7 @@ export function Hero() {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-[12px] phone:[&>a]:w-full">
|
||||
<ButtonLink href="#constructor">
|
||||
Собрать конфигурацию
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<ButtonLink href="#request" variant="ghost">
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
<ButtonLink href="#request">Получить консультацию</ButtonLink>
|
||||
</div>
|
||||
|
||||
<div className="mt-[34px] grid max-w-[790px] grid-cols-3 gap-[10px] phone:grid-cols-[1fr]">
|
||||
|
||||
@@ -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)]" />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
return (
|
||||
<h2 className={cx('mb-[18px] max-w-[920px] text-[clamp(34px,5vw,62px)] leading-[1.01]', className)}>{children}</h2>
|
||||
)
|
||||
return <h2 className={cx('mb-[18px] max-w-[920px]', headingScale, className)}>{children}</h2>
|
||||
}
|
||||
|
||||
export function SectionLead({ tone = 'light', children }: { tone?: 'light' | 'dark'; children: ReactNode }) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { ConsentCheckbox, Honeypot, TextField, TextareaField } from './FormField'
|
||||
import { ArrowRightIcon } from './Icons'
|
||||
|
||||
const EMPTY = {
|
||||
@@ -11,8 +10,6 @@ const EMPTY = {
|
||||
company: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
club_format: clubFormats[0],
|
||||
comment: '',
|
||||
}
|
||||
|
||||
@@ -26,7 +23,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
|
||||
const [pending, setPending] = useState(false)
|
||||
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(() => {
|
||||
if (commentPrefill) setValues((current) => ({ ...current, comment: commentPrefill }))
|
||||
}, [commentPrefill])
|
||||
@@ -71,7 +68,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
|
||||
onSubmit={onSubmit}
|
||||
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>
|
||||
@@ -89,11 +86,10 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
|
||||
/>
|
||||
<TextField
|
||||
id="company"
|
||||
label="Фитнес-клуб / сеть *"
|
||||
label="Фитнес-клуб / сеть"
|
||||
name="company"
|
||||
autoComplete="organization"
|
||||
placeholder="Название клуба"
|
||||
required
|
||||
value={values.company}
|
||||
onChange={set('company')}
|
||||
/>
|
||||
@@ -110,38 +106,20 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
|
||||
/>
|
||||
<TextField
|
||||
id="email"
|
||||
label="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="Площадь, поток, задачи клуба, наличие медицинской лицензии"
|
||||
placeholder="Площадь, поток, задачи клуба, желаемый формат запуска"
|
||||
value={values.comment}
|
||||
onChange={set('comment')}
|
||||
/>
|
||||
@@ -152,7 +130,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
|
||||
<ConsentCheckbox checked={consent} onChange={setConsent} />
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Отправляем…' : 'Получить предложение'}
|
||||
{pending ? 'Отправляем…' : 'Получить консультацию'}
|
||||
{pending ? null : <ArrowRightIcon />}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -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>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Container, Label, Section, headingScale } from './Layout'
|
||||
import { LeadForm } from './LeadForm'
|
||||
|
||||
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="p-[clamp(28px,5vw,58px)]">
|
||||
<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>
|
||||
<p className="max-w-[610px] text-[16px] text-[#B6C9D4]">
|
||||
|
||||
@@ -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]"
|
||||
className="text-[13px] whitespace-nowrap phone:text-[11px]"
|
||||
>
|
||||
Получить конфигурацию
|
||||
Получить консультацию
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ecosystem, legalOptions, proofStats } from '../data/content'
|
||||
import { ecosystem, placementOptions, 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>()
|
||||
const placement = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<Section id="why-exo" tone="dark">
|
||||
@@ -40,12 +40,16 @@ export function WhyExoSection() {
|
||||
</div>
|
||||
|
||||
<aside
|
||||
ref={legal.ref}
|
||||
className={cx('rounded-[28px] bg-white p-[clamp(23px,4vw,36px)] text-ink', legal.revealClass)}
|
||||
ref={placement.ref}
|
||||
className={cx('rounded-[28px] bg-white p-[clamp(23px,4vw,36px)] text-ink', placement.revealClass)}
|
||||
>
|
||||
<Eyebrow>Юридическая рамка</Eyebrow>
|
||||
<h3 className="mb-[18px] text-[27px]">Сценарий фиксируется до запуска</h3>
|
||||
{legalOptions.map((option, index) => (
|
||||
<Eyebrow>Форматы размещения</Eyebrow>
|
||||
<h3 className="mb-[12px] text-[27px]">12 м², которые работают на выручку клуба</h3>
|
||||
<p className="mb-[18px] text-[13px] leading-[1.55] text-muted">
|
||||
Работу сервиса может сопровождать обученный сотрудник клуба — тренер, администратор или оператор. Формат
|
||||
выбирается по площади, потоку и желаемой пропускной способности.
|
||||
</p>
|
||||
{placementOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.title}
|
||||
className={cx('py-[16px]', index === 0 ? 'pt-0' : 'border-t border-navy/[0.12]')}
|
||||
|
||||
+14
-16
@@ -12,15 +12,15 @@ export const contacts = {
|
||||
}
|
||||
|
||||
export const navLinks = [
|
||||
{ href: '#market', label: 'Рынок' },
|
||||
{ href: '#constructor', label: 'Конструктор' },
|
||||
{ href: '#formats', label: 'Визуализации' },
|
||||
{ href: '#why-exo', label: 'Экосистема' },
|
||||
{ href: '#fitness-economics', label: 'Экономика' },
|
||||
{ href: '#constructor', label: 'Оборудование' },
|
||||
{ href: '#programs', label: 'Программы' },
|
||||
{ href: '#formats', label: 'Интерьер' },
|
||||
]
|
||||
|
||||
export const heroFacts = [
|
||||
{ title: '3 аппарата', text: 'компактное ядро под задачи клуба' },
|
||||
{ title: '5 аппаратов', text: 'полный маршрут recovery и спорта' },
|
||||
{ title: '34 из 100', text: 'клиентов клуб не удерживает за год при среднем показателе 66%' },
|
||||
{ title: '4 источника выручки', text: 'сессии, пакеты, premium-сервис и персональные тренировки' },
|
||||
{
|
||||
title: 'Окупаемость',
|
||||
emphasis: 'от 6 месяцев',
|
||||
@@ -244,26 +244,24 @@ export const ecosystem = [
|
||||
{ title: 'Масштаб', text: 'типовой формат для сети' },
|
||||
]
|
||||
|
||||
export const legalOptions = [
|
||||
export const placementOptions = [
|
||||
{
|
||||
title: 'Медицинская Recovery Zone',
|
||||
text: 'Врач или медицинский специалист, допуск, назначение, протокол, противопоказания, санитарный регламент и лицензируемая деятельность.',
|
||||
title: '1 кабинет — 12 м²',
|
||||
text: 'Компактный старт для одного потока клиентов и базового набора программ восстановления.',
|
||||
},
|
||||
{
|
||||
title: 'Wellness-only',
|
||||
text: 'Отдельная модель без диагнозов, лечебных обещаний и медицинских показаний — recovery, расслабление и забота о самочувствии.',
|
||||
title: '2 кабинета — 24 м²',
|
||||
text: 'Два параллельных потока: активные и более автономные программы можно разделить по кабинетам.',
|
||||
},
|
||||
{
|
||||
title: 'Планировка',
|
||||
text: 'Для лицензируемого медицинского формата в материалах EXO предусмотрены решения от 35 м² для двух кабинетов и от 45 м² для трёх.',
|
||||
title: '3 кабинета — 36 м²',
|
||||
text: 'Расширенный формат для высокой загрузки клуба и одновременной работы нескольких программ.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ctaChecklist = [
|
||||
'состав аппаратов и роли каждого в зоне',
|
||||
'состав оборудования и роль каждого аппарата в сервисе восстановления',
|
||||
'вариант планировки и внешний вид в интерьере клуба',
|
||||
'меню программ, обучение и сценарии рекомендаций',
|
||||
'план запуска и контрольные показатели пилота',
|
||||
]
|
||||
|
||||
export const clubFormats = ['Один клуб', 'Сеть клубов', 'Premium-клуб', 'Студия / performance', 'Пока не определён']
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Device {
|
||||
role: string
|
||||
benefits: string[]
|
||||
caption: string
|
||||
/** Short "what it works on" line under the caption on the picker card. */
|
||||
indications: string
|
||||
image: string
|
||||
}
|
||||
|
||||
@@ -20,35 +22,40 @@ export const devices = {
|
||||
name: 'ЭкзоТерапия',
|
||||
role: 'флагманский full-body recovery и premium-продукт',
|
||||
benefits: ['заметный якорь зоны', 'восстановительный этап после нагрузки', 'повышает технологичность сервиса'],
|
||||
caption: 'Флагманский full-body recovery',
|
||||
caption: 'Full-body recovery',
|
||||
indications: 'ОДА • неврология • восстановление после нагрузки',
|
||||
image: deviceTherapy,
|
||||
},
|
||||
magnet: {
|
||||
name: 'ЭкзоМагнит 3 в 1',
|
||||
role: 'medical-core для нейромышечных и опорно-двигательных маршрутов',
|
||||
benefits: ['магнит + УВТ + инфракрасный модуль', 'работа по медицинскому протоколу', 'ядро для спортивной медицины'],
|
||||
caption: 'Нейромышечное и medical-core ядро',
|
||||
caption: 'Нейромышечное ядро',
|
||||
indications: 'суставы • позвоночник • мышцы • сухожилия',
|
||||
image: deviceMagnet,
|
||||
},
|
||||
tecar: {
|
||||
name: 'ЭкзоТекар 3 в 1',
|
||||
role: 'активная локальная работа с тканями, фасциями и подвижностью',
|
||||
benefits: ['TECAR + УВТ + ультразвук', 'инструмент специалиста', 'сильная связь с PT и return-to-sport'],
|
||||
caption: 'Ткани, фасции, локальная работа',
|
||||
caption: 'Локальная работа с тканями',
|
||||
indications: 'мышцы • связки • сухожилия • фасции',
|
||||
image: deviceTecar,
|
||||
},
|
||||
press: {
|
||||
name: 'ЭкзоПресс',
|
||||
role: 'потоковый recovery для ног после бега, leg day и нагрузки',
|
||||
benefits: ['компрессия + тепло / холод', 'понятный сервис для массового спроса', 'легко включается в пакеты'],
|
||||
caption: 'Ноги, компрессия, потоковый формат',
|
||||
caption: 'Потоковое восстановление ног',
|
||||
indications: 'отёки • лимфоотток • тяжесть в ногах',
|
||||
image: devicePress,
|
||||
},
|
||||
hydrogen: {
|
||||
name: 'ЭкзоВодород',
|
||||
role: 'автономная lounge-процедура для recovery и recharge',
|
||||
benefits: ['спокойный формат в кресле', 'минимальное участие оператора', 'отдельная причина прийти в recovery-day'],
|
||||
caption: 'Lounge recovery и перезагрузка',
|
||||
caption: 'Lounge recovery',
|
||||
indications: 'системное восстановление • антистресс • метаболизм',
|
||||
image: deviceHydrogen,
|
||||
},
|
||||
} satisfies Record<string, Device>
|
||||
|
||||
@@ -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 {
|
||||
mode,
|
||||
selected,
|
||||
@@ -76,7 +80,6 @@ export function useConstructor() {
|
||||
applyPreset,
|
||||
reset,
|
||||
benefits: benefits.slice(0, 4),
|
||||
isValid: mode === 5 || selected.length === MAX_IN_THREE_MODE,
|
||||
hint:
|
||||
mode === 5
|
||||
? 'Полная линейка выбрана'
|
||||
@@ -88,19 +91,27 @@ export function useConstructor() {
|
||||
title:
|
||||
mode === 5
|
||||
? 'Полная зона — 5 аппаратов'
|
||||
: matchedPreset
|
||||
? `${matchedPreset[1].title} — 3 аппарата`
|
||||
: 'Персональный микс — 3 аппарата',
|
||||
: incomplete
|
||||
? `Выбрано ${selected.length} из ${MAX_IN_THREE_MODE} аппаратов`
|
||||
: matchedPreset
|
||||
? `${matchedPreset[1].title} — 3 аппарата`
|
||||
: 'Персональный микс — 3 аппарата',
|
||||
subtitle:
|
||||
mode === 5
|
||||
? 'Полный маршрут: active medical-core, premium recovery, потоковые и автономные процедуры.'
|
||||
: matchedPreset
|
||||
? matchedPreset[1].subtitle
|
||||
: 'Состав собран под выбранные вами приоритеты. Итоговая логика уточняется после аудита клуба.',
|
||||
: incomplete
|
||||
? 'Можно продолжить подбор или получить консультацию — специалист поможет собрать оптимальную конфигурацию.'
|
||||
: 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}`,
|
||||
configurationValue: selected.length
|
||||
? `${selected.length} аппарата: ${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])
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
@custom-variant phone (@media (max-width: 520px));
|
||||
@custom-variant compact (@media (max-width: 680px));
|
||||
@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 {
|
||||
html {
|
||||
|
||||
@@ -3,6 +3,11 @@ 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)} ₽`
|
||||
|
||||
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. */
|
||||
export function parseAmount(raw: string): number {
|
||||
return Number(String(raw || '').replace(/\s/g, '').replace(',', '.')) || 0
|
||||
|
||||
Reference in New Issue
Block a user