diff --git a/fitnes/src/components/EconomicsSection.tsx b/fitnes/src/components/EconomicsSection.tsx
index 99e16ec..8a44142 100644
--- a/fitnes/src/components/EconomicsSection.tsx
+++ b/fitnes/src/components/EconomicsSection.tsx
@@ -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() {
Экономика действующей клиентской базы
-
+
Клуб теряет деньги не на тренировке — а между посещениями
@@ -145,16 +145,28 @@ export function EconomicsSection() {
function RevenueCalculator() {
const { ref, revealClass } = useReveal()
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 (
-
-
+
+
+
@@ -229,12 +251,34 @@ function RevenueCalculator() {
value={ready ? formatMoney(month * 12) : '—'}
note="до вычета расходов"
/>
+
+
+
+
+ Средний чек программы составляет 2 500 ₽ , по данным нашей клиники
+ «ЭкзоКлиник» в городе Тольятти за период 2025–2026гг.
+
+
+
+ Средний чек программы может меняться в зависимости от региона и сезонных предложений.
+
+
+
+
- Расчёт показывает сценарную выручку, а не финансовую гарантию. Итоговая модель уточняется по конфигурации
- аппаратов, тарифам, ФОТ, загрузке и формату работы Recovery Zone.
+ Расчёт показывает сценарную выручку и ROI, а не финансовую гарантию. ROI рассчитан без учёта ФОТ, налогов,
+ аренды, расходников и прочих операционных расходов. Финальная экономика уточняется после аудита.
)
diff --git a/fitnes/src/components/FormField.tsx b/fitnes/src/components/FormField.tsx
index b4c9944..bdcc5a2 100644
--- a/fitnes/src/components/FormField.tsx
+++ b/fitnes/src/components/FormField.tsx
@@ -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 (
-
-
- {options.map((option) => (
- {option}
- ))}
-
-
- )
-}
-
export function TextareaField({
id,
label,
diff --git a/fitnes/src/components/Hero.tsx b/fitnes/src/components/Hero.tsx
index ff88d9f..220a92d 100644
--- a/fitnes/src/components/Hero.tsx
+++ b/fitnes/src/components/Hero.tsx
@@ -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() {
-
EXO Performance / Recovery Zone
+
Зона восстановления и спортивной эффективности
@@ -40,13 +39,7 @@ export function Hero() {
-
- Собрать конфигурацию
-
-
-
- Получить предложение
-
+
Получить консультацию
diff --git a/fitnes/src/components/Layout.tsx b/fitnes/src/components/Layout.tsx
index 59ef5e6..fc520e2 100644
--- a/fitnes/src/components/Layout.tsx
+++ b/fitnes/src/components/Layout.tsx
@@ -45,10 +45,15 @@ export function LabelDot() {
return
}
+/**
+ * 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 (
-
{children}
- )
+ return
{children}
}
export function SectionLead({ tone = 'light', children }: { tone?: 'light' | 'dark'; children: ReactNode }) {
diff --git a/fitnes/src/components/LeadForm.tsx b/fitnes/src/components/LeadForm.tsx
index 35df036..cf9f047 100644
--- a/fitnes/src/components/LeadForm.tsx
+++ b/fitnes/src/components/LeadForm.tsx
@@ -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
({ 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"
>
- Получить конфигурацию
+ Получить консультацию
Выбранный в конструкторе состав автоматически добавится в заявку.
@@ -89,11 +86,10 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
/>
@@ -110,38 +106,20 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
/>
-
-
@@ -152,7 +130,7 @@ export function LeadForm({ configuration, commentPrefill }: { configuration: str
- {pending ? 'Отправляем…' : 'Получить предложение'}
+ {pending ? 'Отправляем…' : 'Получить консультацию'}
{pending ? null : }
diff --git a/fitnes/src/components/MobileCta.tsx b/fitnes/src/components/MobileCta.tsx
index 0a74b4e..0dd502b 100644
--- a/fitnes/src/components/MobileCta.tsx
+++ b/fitnes/src/components/MobileCta.tsx
@@ -12,7 +12,7 @@ export function MobileCta({ show, onCallbackClick }: { show: boolean; onCallback
)}
>
- Получить конфигурацию
+ Получить консультацию
Персональная конфигурация
-
+
Подберем индивидуальный комплект оборудования точно под формат вашего клуба.
diff --git a/fitnes/src/components/SiteHeader.tsx b/fitnes/src/components/SiteHeader.tsx
index 9b7d1fd..3a838e1 100644
--- a/fitnes/src/components/SiteHeader.tsx
+++ b/fitnes/src/components/SiteHeader.tsx
@@ -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]"
>
- Получить конфигурацию
+ Получить консультацию
diff --git a/fitnes/src/components/WhyExoSection.tsx b/fitnes/src/components/WhyExoSection.tsx
index eadb52f..5762d7d 100644
--- a/fitnes/src/components/WhyExoSection.tsx
+++ b/fitnes/src/components/WhyExoSection.tsx
@@ -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()
- const legal = useReveal()
+ const placement = useReveal()
return (
@@ -40,12 +40,16 @@ export function WhyExoSection() {
- Юридическая рамка
- Сценарий фиксируется до запуска
- {legalOptions.map((option, index) => (
+ Форматы размещения
+ 12 м², которые работают на выручку клуба
+
+ Работу сервиса может сопровождать обученный сотрудник клуба — тренер, администратор или оператор. Формат
+ выбирается по площади, потоку и желаемой пропускной способности.
+
+ {placementOptions.map((option, index) => (
diff --git a/fitnes/src/hooks/useConstructor.ts b/fitnes/src/hooks/useConstructor.ts
index ef36de6..b86e2aa 100644
--- a/fitnes/src/hooks/useConstructor.ts
+++ b/fitnes/src/hooks/useConstructor.ts
@@ -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])
}
diff --git a/fitnes/src/index.css b/fitnes/src/index.css
index a689843..3164c00 100644
--- a/fitnes/src/index.css
+++ b/fitnes/src/index.css
@@ -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 {
diff --git a/fitnes/src/lib/format.ts b/fitnes/src/lib/format.ts
index b085700..da16639 100644
--- a/fitnes/src/lib/format.ts
+++ b/fitnes/src/lib/format.ts
@@ -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