Files
exodevices/fitnes/src/components/EconomicsSection.tsx
T
Yuriy PanovandClaude Opus 5 432284eefb 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>
2026-09-11 18:37:03 +06:00

378 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { econFormula, econGain, econLoss, econMetrics, econOps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { formatDecimal, formatMoney, formatNumber, parseAmount } from '../lib/format'
import { cx } from '../lib/cx'
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]'
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={cx('max-w-[900px] text-navy', headingScale)}>
Клуб теряет деньги не на тренировке а между посещениями
</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('')
// 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}
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-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="Оплаченных программ восстановления в день"
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}
/>
<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]">
<CalcResult
label="Дополнительная выручка / месяц"
value={ready ? formatMoney(month) : '—'}
note={
ready
? `${formatNumber(programsValue)} × ${formatNumber(daysValue)} = ${formatNumber(volume)} программ в месяц`
: 'Введите программы в день и средний чек'
}
/>
<CalcResult
label="Дополнительная выручка / год"
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>, по данным нашей клиники
«ЭкзоКлиник» в городе Тольятти за период 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]">
Расчёт показывает сценарную выручку и ROI, а не финансовую гарантию. ROI рассчитан без учёта ФОТ, налогов,
аренды, расходников и прочих операционных расходов. Финальная экономика уточняется после аудита.
</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>
)
}