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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user