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), общая для клиента и сервера
|
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 − инвестиции) ÷ инвестиции.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Известные особенности
|
## Известные особенности
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>, по данным нашей клиники
|
||||||
|
«ЭкзоКлиник» в городе Тольятти за период 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]">
|
<p className="mt-[13px] text-[9px] leading-[1.45] text-white/[0.43]">
|
||||||
Расчёт показывает сценарную выручку, а не финансовую гарантию. Итоговая модель уточняется по конфигурации
|
Расчёт показывает сценарную выручку и ROI, а не финансовую гарантию. ROI рассчитан без учёта ФОТ, налогов,
|
||||||
аппаратов, тарифам, ФОТ, загрузке и формату работы Recovery Zone.
|
аренды, расходников и прочих операционных расходов. Финальная экономика уточняется после аудита.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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({
|
export function TextareaField({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,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]">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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', 'Пока не определён']
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user