Rename medcenter landings and add the revenue calculator

Rename the two medcenter landings to their audience names:
medcenter -> medcenterphysio, medcenterpersonal -> medcenterstart.

Alongside the rename:
- add a RevenueCalculator section to both landings;
- rework the copy and figures in src/data/content.ts;
- simplify the lead form: drop the "cabinet_state" and "profile"
  selects (along with SelectField and the matching fields in
  shared/lead.ts, lead-mapper.ts and amo-check.ts) and make
  company and email optional;
- add the legacy/new static prototypes for both landings;
- add pnpm-lock.yaml to medcenterstart (package-lock.json is still
  there too).

deploy/apps.conf and deploy/README.md still refer to the old
directory names and need a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-06 23:19:46 +06:00
co-authored by Claude Opus 5
parent be7741e269
commit 0732aa2096
170 changed files with 5342 additions and 205 deletions
+79
View File
@@ -0,0 +1,79 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
import { cx } from '../lib/cx'
export type ButtonVariant = 'primary' | 'secondary' | 'call'
const base =
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none ' +
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
'[&>svg]:size-[18px] [&>svg]:shrink-0'
/**
* `whitespace-nowrap` belongs on buttons sized by their label, not on every
* button. A full-width button whose label cannot wrap reports a min-content
* width of its whole label — 358px for «Получить предварительный аудит» — and
* that width propagates out through any `auto` grid track around it. That is
* what pushed the whole `#proposal` section 47px off a 375px screen and let
* `overflow-hidden` slice the copy and the form fields off the right edge.
*/
export const nowrap = 'whitespace-nowrap'
/**
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
* conflicting utilities by their order in the stylesheet, not by the order they
* appear in `className`, so a `min-h-[44px]` passed next to the default
* `min-h-[52px]` would silently lose. Replacing the slot means the losing class
* is never emitted in the first place.
*/
const defaultSize = 'min-h-[52px] px-[22px]'
/** `.btn--header` — smaller, and tighter still on the narrowest phones. */
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] whitespace-nowrap phone:px-[14px] phone:text-[12px]'
const variants: Record<ButtonVariant, string> = {
primary:
'bg-teal text-navy shadow-[0_16px_45px_rgb(0_196_180/0.26)] hover:bg-teal-bright hover:shadow-[0_20px_55px_rgb(0_196_180/0.34)]',
secondary:
'border-white/[0.28] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.44] hover:bg-white/[0.14]',
call: 'border-white/[0.22] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.38] hover:bg-white/[0.14] [&>svg]:size-[16px]',
}
export function buttonClass(variant: ButtonVariant, className?: string, size: string = defaultSize) {
return cx(base, size, variants[variant], className)
}
interface CommonProps {
variant?: ButtonVariant
/** Replaces the default min-height/padding pair. See `defaultSize` above. */
size?: string
className?: string
children: ReactNode
}
export function Button({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size'>) {
return (
<button className={buttonClass(variant, className, size)} {...rest}>
{children}
</button>
)
}
export function ButtonLink({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a className={buttonClass(variant, className, size)} {...rest}>
{children}
</a>
)
}
@@ -0,0 +1,98 @@
import { devices, equipmentNote } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, Section, SectionHead } from './Layout'
import { Rail } from './Rail'
/**
* Product shots differ in framing, so the last stylesheet pass in the original
* sized two of them separately. Keyed by the device title.
*/
const mediaFit: Record<string, string> = {
ЭкзоИмпульс: 'max-w-[88%] max-h-[88%] translate-y-[10px]',
'ЭкзоЛазер В': 'max-w-[82%] max-h-[82%] max-[720px]:max-w-[86%] max-[720px]:max-h-[86%]',
}
const defaultFit = 'max-w-[86%] max-h-[86%] translate-y-[8px]'
/** The laser shot is padded evenly instead of being nudged up off the base. */
const mediaBox: Record<string, string> = {
'ЭкзоЛазер В': 'p-[20px]',
}
const defaultBox = 'px-[18px] pt-[18px] pb-[14px]'
export function EquipmentSection() {
const track = useReveal<HTMLDivElement>()
const note = useReveal<HTMLDivElement>()
return (
<Section id="technology" tone="dark" className="overflow-hidden">
<Container>
<SectionHead
split
tone="dark"
eyebrow="Технологическое ядро"
title="Состав под профиль центра и клинические маршруты"
lead="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования."
/>
<Rail
outerRef={track.ref}
wrapperClassName={track.revealClass}
tone="dark"
label="Оборудование Экзо Групп"
className="scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3"
>
{devices.map((device) => (
<article
key={device.title}
className="equipment-card-surface flex min-h-[430px] flex-col overflow-hidden rounded-[26px] border border-white/[0.13] md:min-h-[425px]"
>
<div
className={cx(
'equipment-media-surface flex h-[300px] items-center justify-center overflow-hidden',
mediaBox[device.title] ?? defaultBox,
)}
>
<img
alt={device.alt}
className={cx(
'block h-auto w-auto object-contain object-center drop-shadow-[0_18px_24px_rgb(0_0_0/0.28)]',
mediaFit[device.title] ?? defaultFit,
)}
loading="lazy"
src={device.image}
/>
</div>
<div className="flex flex-1 flex-col p-[22px]">
<span className="mb-[8px] text-[11px] font-[850] tracking-[0.12em] text-teal-bright uppercase">
{device.kicker}
</span>
<h3 className="text-[27px] text-white">{device.title}</h3>
<p className="mb-[18px] text-[14px] text-white/[0.66]">{device.indications}</p>
<div className="mt-auto border-t border-white/[0.12] pt-[15px] text-[13px] font-[720] text-[#C7FFF9]">
{device.role}
</div>
</div>
</article>
))}
</Rail>
<div
ref={note.ref}
className={cx(
'mt-[22px] flex items-start gap-[12px] rounded-md border border-teal/[0.26] bg-teal/[0.09] px-[20px] py-[18px] text-[13px] text-white/[0.76]',
'[&>svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright',
note.revealClass,
)}
>
<CheckIcon />
<span>{equipmentNote}</span>
</div>
</Container>
</Section>
)
}
@@ -0,0 +1,63 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import { cx } from '../lib/cx'
/** Glass controls on the navy CTA panel. */
const controlClass =
'w-full rounded-[14px] border border-white/[0.18] bg-white/[0.08] text-white outline-none ' +
'transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-white/[0.42] ' +
'focus:border-teal focus:bg-white/[0.12] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.12)]'
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
return (
<div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}>
<label htmlFor={id} className="text-[12px] font-[750] text-white/[0.7]">
{label}
</label>
{children}
</div>
)
}
export function TextField({
id,
label,
full,
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
return (
<Field id={id} label={label} full={full}>
<input id={id} className={cx(controlClass, 'min-h-[52px] px-[15px]')} {...rest} />
</Field>
)
}
export function TextareaField({
id,
label,
full,
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
return (
<Field id={id} label={label} full={full}>
<textarea id={id} className={cx(controlClass, 'min-h-[104px] resize-y px-[15px] pt-[14px]')} {...rest} />
</Field>
)
}
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<div aria-hidden="true" className="honeypot">
<label htmlFor="website">Не заполнять</label>
<input
autoComplete="off"
id="website"
name="website"
tabIndex={-1}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import heroBg from '../assets/images/hero-bg.webp'
import { heroFacts } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { Container, Eyebrow } from './Layout'
export function Hero() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
return (
<section
id="top"
aria-labelledby="hero-title"
className="relative isolate flex min-h-[780px] items-end overflow-hidden bg-navy-deep pt-[126px] pb-[48px] text-white
compact:min-h-[660px] compact:pt-[132px] compact:pb-[36px] lg:min-h-[810px]"
>
<div
aria-hidden="true"
className="hero-photo absolute inset-0 -z-3"
style={{ backgroundImage: `url(${heroBg})` }}
/>
<div aria-hidden="true" className="hero-scrim absolute inset-0 -z-2" />
<div
aria-hidden="true"
className="hero-bloom pointer-events-none absolute -bottom-[240px] -left-[150px] -z-1 size-[650px] rounded-full blur-[8px]"
/>
<Container className="relative z-2">
<div ref={ref} className={cx('max-w-[860px]', revealClass)}>
<Eyebrow tone="dark">Решение для медицинского центра без физиотерапии</Eyebrow>
<h1 id="hero-title">Физиотерапия с нуля готовое направление под поток вашего центра</h1>
<p className="mb-[28px] max-w-[760px] text-[clamp(19px,2vw,24px)] leading-[1.52] text-white/[0.8] compact:mb-[22px] compact:text-[17px]">
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
клинические маршруты, обучаем команду.
</p>
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] compact:mb-[26px] sm:flex-row sm:items-center">
<ButtonLink href="#proposal">
Получить предварительный аудит
<ArrowRightIcon />
</ButtonLink>
</div>
{/* Two specs and a claim, not three facts. On a phone the third one
stops being an orphan on its own row and starts being the point:
it is the only number here about money. */}
<div
aria-label="Ключевые параметры проекта"
className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:gap-[9px] phone:[&>*:last-child]:col-span-full"
>
{heroFacts.map((fact, index) => {
const claim = index === heroFacts.length - 1
return (
<div
key={fact.value}
className={cx(
'min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]',
'phone:min-h-0 phone:px-[14px] phone:py-[13px]',
claim && 'phone:border-teal/[0.42] phone:bg-teal/[0.11]',
)}
>
<strong
className={cx(
'mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]',
claim && 'phone:text-[#9FF6EC]',
)}
>
{fact.value}
</strong>
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
</div>
)
})}
</div>
<div className="mt-[18px] inline-flex items-center gap-[9px] text-[14px] font-[720] text-[#C8FFF9]">
<i aria-hidden="true" className="size-[8px] rounded-full bg-teal shadow-[0_0_0_7px_rgb(0_196_180/0.14)]" />
Новый аппаратный этап внутри уже существующего маршрута пациента
</div>
</div>
</Container>
</section>
)
}
+105
View File
@@ -0,0 +1,105 @@
import type { ReactNode } from 'react'
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
function Icon({ className, children }: { className?: string; children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className={className}>
{children}
</svg>
)
}
export function ArrowRightIcon() {
return (
<Icon>
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function CheckIcon() {
return (
<Icon>
<path d="m5 12 4 4L19 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.2" />
</Icon>
)
}
export function ChevronUpIcon({ className }: { className?: string }) {
return (
<Icon className={className}>
<path d="m6 15 6-6 6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function CloseIcon({ className }: { className?: string }) {
return (
<Icon className={className}>
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
)
}
export function PhoneIcon() {
return (
<Icon>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.35 1.78.68 2.62a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.46-1.25a2 2 0 0 1 2.11-.45c.84.33 1.72.56 2.62.68A2 2 0 0 1 22 16.92Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</Icon>
)
}
/** Icons of the four promise cards, keyed by `promises[].icon`. */
export const promiseIcons = {
retain: (
<Icon>
<path d="M4 19V7l8-4 8 4v12H4Z M8 19v-6h8v6" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
revenue: (
<Icon>
<path d="M4 17 9 12l4 4 7-9 M20 7v5h-5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
clinical: (
<Icon>
<path d="M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
</Icon>
),
metrics: (
<Icon>
<path d="M5 20V10M12 20V4M19 20v-7" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
),
}
/** Icons of the «почему Экзо Групп» list, keyed by `whyItems[].icon`. */
export const whyIcons = {
production: (
<Icon>
<path d="M4 17V7l8-4 8 4v10l-8 4-8-4Z M9 9h6v6H9z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
),
clinics: (
<Icon>
<path d="M5 20v-8M12 20V4M19 20v-5M3 20h18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
),
shield: (
<Icon>
<path
d="M8 12l3 3 5-6M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</Icon>
),
}
@@ -0,0 +1,145 @@
import { useState } from 'react'
import { formatNote, formats, timeline } from '../data/content'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ChevronUpIcon } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function LaunchSection() {
const grid = useReveal<HTMLDivElement>()
const steps = useReveal<HTMLDivElement>()
const stacked = useMediaQuery('(max-width: 899px)')
return (
<Section id="launch" tone="white">
<Container>
<SectionHead
split
eyebrow="Пространство и запуск"
title="Начать можно с одного кабинета и расширяться после подтверждения KPI"
lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы."
/>
<Rail
outerRef={grid.ref}
wrapperClassName={grid.revealClass}
label="Форматы кабинета"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3"
>
{formats.map((format) => (
<article
key={format.area}
className="relative overflow-hidden rounded-[22px] border border-navy/[0.12] bg-white p-[24px]"
>
<span
aria-hidden="true"
className="absolute -top-[30px] -right-[30px] size-[110px] rounded-full bg-teal/[0.08]"
/>
<span className="mb-[9px] block text-[clamp(36px,5vw,52px)] leading-none font-[850] tracking-[-0.055em] text-navy">
{format.area}
</span>
<h3 className="text-[20px]">{format.title}</h3>
<p className="text-[14px] text-muted">{format.text}</p>
</article>
))}
</Rail>
<SmallNote className="mt-[16px]">{formatNote}</SmallNote>
{/* Twelve weeks in order — deliberately not a rail. Sideways scrolling
would hide week 35 from someone still reading week 12, and the
order is the whole point of this block. Below 900px the four weeks
stack along a teal spine and open one at a time: the sequence stays
visible at a glance, and the eighteen bullets underneath it stop
costing 2400px of scroll to walk past. */}
<div
ref={steps.ref}
className={cx(
'mt-[30px] grid grid-cols-[minmax(0,1fr)] gap-[14px]',
'relative pl-[26px] before:absolute before:top-[16px] before:bottom-[16px] before:left-[5px] before:w-[2px]',
"before:rounded-full before:bg-[linear-gradient(180deg,var(--color-teal),rgb(0_196_180/0.14))] before:content-['']",
'min-[900px]:grid-cols-2 min-[900px]:gap-[14px] min-[900px]:pl-0 min-[900px]:before:hidden',
steps.revealClass,
)}
>
{timeline.map((step, index) => (
<TimelineStep key={step.week} step={step} defaultOpen={index === 0} collapsible={stacked} />
))}
</div>
</Container>
</Section>
)
}
/**
* One week of the launch plan. A disclosure below 900px, a plain card above it
* — the difference is real markup, not a class, because a heading that is a
* button on a phone should not stay a button on a desktop where nothing
* collapses.
*/
function TimelineStep({
step,
defaultOpen,
collapsible,
}: {
step: (typeof timeline)[number]
defaultOpen: boolean
collapsible: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const expanded = !collapsible || open
const week = (
<span className="block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">{step.week}</span>
)
const title = <strong className="mt-[6px] block text-[17px] leading-[1.2] text-navy">{step.title}</strong>
return (
<article
className="relative rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[20px]
before:absolute before:top-[26px] before:-left-[26px] before:size-[12px] before:rounded-full before:border-[3px]
before:border-white before:bg-teal before:content-[''] min-[900px]:py-[22px] min-[900px]:before:hidden"
>
{collapsible ? (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex w-full cursor-pointer items-start gap-[12px] text-left outline-none focus-visible:rounded-[6px] focus-visible:ring-2 focus-visible:ring-teal"
>
<span className="min-w-0 flex-1">
{week}
{title}
</span>
<ChevronUpIcon
className={cx(
'mt-[3px] size-[18px] shrink-0 text-teal-ink transition-transform duration-250',
open ? 'rotate-0' : 'rotate-180',
)}
/>
</button>
) : (
<>
{week}
{title}
</>
)}
{expanded ? (
<>
<span className="mt-[12px] mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span>
<ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted">
{step.items.map((item) => (
<li key={item.title}>
<strong className="inline text-[13px] text-navy">{item.title}</strong>
{item.text ? `: ${item.text}` : null}
</li>
))}
</ul>
</>
) : null}
</article>
)
}
+153
View File
@@ -0,0 +1,153 @@
import type { ReactNode } from 'react'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
type Tone = 'light' | 'dark'
/** `.container` — 1200px max, 16px gutters that grow to 24px from 760px up. */
export function Container({ className, children }: { className?: string; children: ReactNode }) {
return (
<div
className={cx(
'mx-auto w-[min(calc(100%_-_32px),var(--container-page))] md:w-[min(calc(100%_-_48px),var(--container-page))]',
className,
)}
>
{children}
</div>
)
}
/** `.eyebrow` — small uppercase kicker with the leading teal rule. */
export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<p
className={cx(
'mb-[16px] inline-flex items-center gap-[10px] text-[12px] font-[850] tracking-[0.14em] uppercase',
"before:h-[2px] before:w-[27px] before:rounded-[2px] before:bg-teal before:content-['']",
tone === 'dark' ? 'text-teal-bright' : 'text-teal-ink',
className,
)}
>
{children}
</p>
)
}
/** `.lead` — the paragraph under a section heading. */
export function SectionLead({
tone = 'light',
className,
children,
}: {
tone?: Tone
className?: string
children: ReactNode
}) {
return (
<p
className={cx(
'mb-0 max-w-[780px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
className,
)}
>
{children}
</p>
)
}
/** `.small-note` — the fine print under a section. */
export function SmallNote({
tone = 'light',
className,
children,
}: {
tone?: Tone
className?: string
children: ReactNode
}) {
return (
<p className={cx('text-[13px] leading-[1.55]', tone === 'dark' ? 'text-white/[0.56]' : 'text-muted', className)}>
{children}
</p>
)
}
/**
* `.section-head` — stacked by default; `split` moves the lead into a second
* column from 760px up, bottom-aligned against the heading.
*/
export function SectionHead({
eyebrow,
title,
lead,
tone = 'light',
split = false,
}: {
eyebrow: string
title: ReactNode
lead?: ReactNode
tone?: Tone
split?: boolean
}) {
const { ref, revealClass } = useReveal<HTMLDivElement>()
if (!split) {
return (
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2>{title}</h2>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
return (
<div
ref={ref}
className={cx(
'mb-[clamp(34px,5vw,54px)] grid gap-[20px] md:grid-cols-[minmax(0,1.15fr)_minmax(300px,0.85fr)] md:items-end',
revealClass,
)}
>
<div>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2 className="mb-0">{title}</h2>
</div>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
const sectionTones = {
light: 'bg-paper',
white: 'bg-white',
dark: 'bg-navy text-white',
/** For sections that paint their own multi-layer background in index.css. */
none: '',
}
/** `.section` — the shared vertical rhythm and background variants. */
export function Section({
id,
tone = 'light',
compact = false,
className,
children,
}: {
id?: string
tone?: keyof typeof sectionTones
compact?: boolean
className?: string
children: ReactNode
}) {
return (
<section
id={id}
className={cx('relative', compact ? 'py-section-compact' : 'py-section', sectionTones[tone], className)}
>
{children}
</section>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { useState, type FormEvent } from 'react'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { submitLead } from '../lib/lead'
import { formatRuPhone, isCompleteRuPhone } from '../lib/phone'
import { Button } from './Button'
import { Honeypot, TextField, TextareaField } from './FormField'
import { ArrowRightIcon } from './Icons'
const EMPTY = {
name: '',
company: '',
phone: '',
email: '',
comment: '',
}
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
export function LeadForm() {
const { ref: formRef, revealClass } = useReveal<HTMLFormElement>()
const [values, setValues] = useState(EMPTY)
const [honeypot, setHoneypot] = useState('')
const [pending, setPending] = useState(false)
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
const set = (key: keyof typeof EMPTY) => (event: { target: { value: string } }) =>
setValues((current) => ({ ...current, [key]: event.target.value }))
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = formRef.current
if (!form) return
if (honeypot) return
if (!form.checkValidity()) {
form.reportValidity()
setStatus({ text: 'Проверьте обязательные поля формы.', tone: 'error' })
return
}
if (!isCompleteRuPhone(values.phone)) {
setStatus({ text: 'Укажите полный номер телефона.', tone: 'error' })
form.phone.focus()
return
}
setPending(true)
setStatus({ text: '', tone: 'idle' })
const result = await submitLead('request', { ...values, website: honeypot })
if (result.ok) {
setValues(EMPTY)
setStatus({
text: 'Спасибо. Заявка отправлена — специалист свяжется с вами для предварительного аудита.',
tone: 'success',
})
} else {
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
}
setPending(false)
}
return (
<form
ref={formRef}
id="leadForm"
noValidate
onSubmit={onSubmit}
className={cx(
'min-w-0 rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]',
revealClass,
)}
>
<div className="grid gap-[12px] sm:grid-cols-2">
<TextField
id="name"
label="Имя *"
name="name"
autoComplete="name"
placeholder="Как к вам обращаться"
required
value={values.name}
onChange={set('name')}
/>
<TextField
id="company"
label="Компания / медицинский центр"
name="company"
autoComplete="organization"
placeholder="Название центра"
value={values.company}
onChange={set('company')}
/>
<TextField
id="phone"
label="Телефон *"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
placeholder="+7 900 000-00-00"
required
value={values.phone}
onChange={(event) => setValues((current) => ({ ...current, phone: formatRuPhone(event.target.value) }))}
/>
<TextField
id="email"
label="Email"
name="email"
type="email"
autoComplete="email"
placeholder="name@clinic.ru"
value={values.email}
onChange={set('email')}
/>
<TextareaField
id="comment"
label="Комментарий"
name="comment"
full
placeholder="Количество визитов, площадь, город, задачи проекта"
value={values.comment}
onChange={set('comment')}
/>
<Honeypot value={honeypot} onChange={setHoneypot} />
<div className="sm:col-span-full">
<Button type="submit" className="mt-[4px] w-full" disabled={pending} aria-busy={pending}>
<span>{pending ? 'Отправляем…' : 'Получить предварительный аудит'}</span>
{pending ? null : <ArrowRightIcon />}
</Button>
</div>
</div>
<p className="mt-[10px] text-[11px] text-white/[0.45]">
Нажимая кнопку, вы подтверждаете согласие на обработку данных для подготовки предложения.
</p>
{/* Kept mounted so screen readers announce the result in place. */}
<div
role="status"
aria-live="polite"
className={cx(
status.text && 'mt-[12px] rounded-[13px] border px-[15px] py-[13px] text-[13px]',
status.tone === 'success' && 'border-teal/[0.3] bg-teal/[0.15] text-[#C8FFF9]',
status.tone === 'error' && 'border-[#FF6060]/[0.28] bg-[#FF6060]/[0.12] text-[#FFD6D6]',
)}
>
{status.text}
</div>
</form>
)
}
@@ -0,0 +1,127 @@
import { marketNotes, marketStats, routeWith, routeWithout } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function MarketSection() {
const stats = useReveal<HTMLDivElement>()
const gap = useReveal<HTMLElement>()
return (
<Section id="market">
<Container>
<SectionHead
split
eyebrow="Рыночный контекст"
title="Спрос на реабилитацию растёт. Пациент уже находится внутри вашего центра"
lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли."
/>
<div className="grid grid-cols-[minmax(0,1fr)] gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
<Rail
outerRef={stats.ref}
wrapperClassName={stats.revealClass}
label="Рыночные показатели"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2"
>
{marketStats.map((stat) => (
/* The source line used to be absolutely positioned against the
card's min-height, so the longest label ran underneath it.
It is in the flow now, pinned to the bottom by `mt-auto`. */
<article
key={stat.label}
className="relative flex min-h-[185px] flex-col overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
phone:min-h-[168px] phone:p-[20px] sm:min-h-[205px]"
>
<span
aria-hidden="true"
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full"
/>
<strong className="relative mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
{stat.value}
</strong>
<p className="relative max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
<span className="relative mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
{stat.source}
</span>
</article>
))}
</Rail>
<article
ref={gap.ref}
className={cx(
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep compact:p-[20px]',
gap.revealClass,
)}
>
<span
aria-hidden="true"
className="teal-bloom absolute -right-[160px] -bottom-[200px] size-[480px] rounded-full"
/>
<div className="relative z-1">
<Eyebrow tone="dark">Исходная точка</Eyebrow>
<h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)] compact:text-[22px] compact:tracking-[-0.02em]">
Маршрут пациента: как перестать отдавать прибыль конкурентам.
</h3>
</div>
{/* Not `md:` — at 760 the card is already sharing the row with the
stats, so splitting it again gives two 165px columns of
four-word lines. It waits for 980, where the card is wide. */}
<div className="relative z-1 grid gap-[12px] lg:grid-cols-2">
<RouteColumn sign="—" title="Пока физиотерапии нет" items={routeWithout} />
<RouteColumn sign="+" title="После запуска направления" items={routeWith} accent />
</div>
</article>
</div>
<SmallNote className="mt-[16px]">{marketNotes[0]}</SmallNote>
<SmallNote className="mt-[8px]">{marketNotes[1]}</SmallNote>
</Container>
</Section>
)
}
function RouteColumn({
sign,
title,
items,
accent = false,
}: {
sign: string
title: string
items: string[]
accent?: boolean
}) {
return (
<div
className={cx(
'rounded-[20px] border p-[20px]',
accent ? 'border-teal/[0.34] bg-teal/[0.13]' : 'border-white/[0.13] bg-white/[0.07]',
)}
>
<div className="mb-[12px] flex items-center gap-[10px] font-[820]">
<span
aria-hidden="true"
className="grid size-[28px] place-items-center rounded-full bg-white/[0.12] text-[13px] text-teal-bright"
>
{sign}
</span>
{title}
</div>
<ul className="grid gap-[9px]">
{items.map((item) => (
<li
key={item}
className="relative pl-[19px] text-[14px] text-white/[0.72] before:absolute before:top-[0.65em] before:left-0 before:size-[7px] before:rounded-full before:bg-teal before:content-['']"
>
{item}
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,81 @@
import { programs, routeSteps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionHead } from './Layout'
import { Rail } from './Rail'
export function ProgramsSection() {
const route = useReveal<HTMLElement>()
return (
<Section id="programs" compact>
<Container>
<SectionHead
eyebrow="Курсовая модель"
title="Новая услуга сразу запускается как маршрут, а не набор разовых процедур"
lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель."
/>
<Rail
label="Клинические программы"
className="scroll-cards auto-cols-[minmax(300px,88%)] gap-[14px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5"
>
{programs.map((program) => (
<ProgramCard key={program.num} {...program} />
))}
</Rail>
<aside
ref={route.ref}
className={cx(
'mt-[24px] rounded-xl bg-navy p-[clamp(24px,4vw,36px)] text-white shadow-deep md:mt-[18px]',
route.revealClass,
)}
>
<Eyebrow tone="dark">Маршрут пациента</Eyebrow>
<h3>Кабинет загружается текущим потоком, а не ожиданием случайных обращений</h3>
<div className="mt-[22px] grid gap-[9px] md:grid-cols-5 md:gap-[8px]">
{routeSteps.map((step, index) => (
<div
key={step.title}
className="grid grid-cols-[42px_1fr] items-start gap-[13px] rounded-[17px] border border-white/[0.11] bg-white/[0.06] p-[15px]
md:block md:min-h-[170px] md:p-[14px]"
>
<span className="grid size-[42px] place-items-center rounded-[13px] bg-teal text-[15px] leading-none font-black text-navy md:mb-[12px]">
{index + 1}
</span>
<div>
<strong className="mb-[2px] block text-[16px]">{step.title}</strong>
<span className="block text-[13px] leading-[1.42] text-white/[0.63]">{step.text}</span>
</div>
</div>
))}
</div>
</aside>
</Container>
</Section>
)
}
function ProgramCard({ num, title, text }: (typeof programs)[number]) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
className={cx(
'min-h-[290px] overflow-hidden rounded-[22px] border border-navy/[0.12] bg-white p-[22px] shadow-[0_15px_45px_rgb(4_30_48/0.055)] md:min-h-[255px]',
revealClass,
)}
>
<span className="mb-[14px] block text-[13px] font-black tracking-[0.12em] text-[#00A99C]">{num}</span>
<h3 className="mb-[12px] text-[clamp(18px,1.7vw,22px)] leading-[1.12] [overflow-wrap:anywhere] [word-break:normal] [hyphens:auto]">
{title}
</h3>
<p className="text-[13px] leading-[1.48] text-muted [overflow-wrap:anywhere] [word-break:normal] [hyphens:auto]">
{text}
</p>
</article>
)
}
@@ -0,0 +1,75 @@
import { profileChips, profileChipsNote, promises, systemStatement } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { promiseIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function ProjectSection() {
const strip = useReveal<HTMLDivElement>()
const panel = useReveal<HTMLDivElement>()
return (
<Section id="project" tone="white">
<Container>
<SectionHead
eyebrow="Готовое направление"
title="Не просто поставка оборудования, а запуск полноценного отделения физиотерапии под ключ"
lead={
<>
Каждый шаг нашей дорожной карты нужен для того, чтобы кабинет приносил реальные деньги, а не просто
числился на балансе клиники. «<strong>Экзо</strong> Групп» комплексно решает задачи медицинского и
операционного запуска.
</>
}
/>
<Rail
outerRef={strip.ref}
wrapperClassName={cx('mb-[24px] sm:mb-[28px]', strip.revealClass)}
label="Что получает центр"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2 md:grid-cols-4"
>
{promises.map((promise) => (
<article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]">
<div className="mb-[14px] grid size-[42px] place-items-center rounded-[13px] bg-ice text-navy [&>svg]:size-[22px]">
{promiseIcons[promise.icon]}
</div>
<h3 className="text-[20px]">{promise.title}</h3>
<p className="text-[14px] text-muted">{promise.text}</p>
</article>
))}
</Rail>
<div
ref={panel.ref}
className={cx(
'system-panel-surface rounded-xl border border-navy/[0.12] p-[clamp(24px,4vw,40px)] shadow-[0_22px_70px_rgb(4_30_48/0.08)]',
panel.revealClass,
)}
>
{/* `.system-bottom` keeps its rule and spacing even though this page
dropped the card grid that used to sit above it. */}
<div className="mt-[24px] grid items-center gap-[18px] border-t border-navy/[0.12] pt-[24px] md:grid-cols-[minmax(0,1fr)_minmax(340px,0.85fr)]">
<div className="text-[clamp(22px,3.3vw,34px)] leading-[1.16] font-extrabold tracking-[-0.035em]">
{systemStatement}
</div>
<div>
<SmallNote className="mb-[10px]">{profileChipsNote}</SmallNote>
<div className="flex flex-wrap gap-[8px]">
{profileChips.map((chip) => (
<span
key={chip}
className="rounded-full bg-ice px-[12px] py-[8px] text-[12px] font-[720] text-[#176A67]"
>
{chip}
</span>
))}
</div>
</div>
</div>
</div>
</Container>
</Section>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { cx } from '../lib/cx'
/**
* A row of peer cards: scrolls sideways on phones, becomes a plain grid at the
* breakpoint the caller sets in `className` (`sm:grid-still` / `md:grid-still`).
*
* Only rows whose cards are unordered peers belong here. Sequences — the launch
* timeline, the patient route — stay stacked, because sideways scrolling hides
* step two from someone still reading step one.
*
* The segmented indicator underneath measures the row instead of guessing from
* a breakpoint, so it appears exactly when the sideways gesture exists and
* disappears the moment the row turns back into a grid.
*/
export function Rail({
label,
tone = 'light',
className,
wrapperClassName,
outerRef,
children,
}: {
label?: string
tone?: 'light' | 'dark'
/** Classes for the scrolling row itself. */
className?: string
/** Classes for the wrapper that holds the row and the indicator. */
wrapperClassName?: string
outerRef?: RefObject<HTMLDivElement | null>
children: ReactNode
}) {
const rowRef = useRef<HTMLDivElement>(null)
const [{ index, count }, setPosition] = useState({ index: 0, count: 0 })
useEffect(() => {
const row = rowRef.current
if (!row) return
const measure = () => {
/* `count: 0` hides the indicator — the row is a grid at this width. */
if (row.scrollWidth - row.clientWidth < 4) {
setPosition((current) => (current.count === 0 ? current : { index: 0, count: 0 }))
return
}
const cards = Array.from(row.children) as HTMLElement[]
const middle = row.getBoundingClientRect().left + row.clientWidth / 2
let nearest = 0
let shortest = Infinity
cards.forEach((card, position) => {
const box = card.getBoundingClientRect()
const distance = Math.abs(box.left + box.width / 2 - middle)
if (distance < shortest) {
shortest = distance
nearest = position
}
})
setPosition((current) =>
current.index === nearest && current.count === cards.length
? current
: { index: nearest, count: cards.length },
)
}
measure()
row.addEventListener('scroll', measure, { passive: true })
const observer = new ResizeObserver(measure)
observer.observe(row)
return () => {
row.removeEventListener('scroll', measure)
observer.disconnect()
}
}, [])
const goTo = (position: number) => {
const row = rowRef.current
const card = row?.children[position] as HTMLElement | undefined
if (!row || !card) return
/* 16px is the row's own inline padding — the resting offset of card one. */
row.scrollBy({
left: card.getBoundingClientRect().left - row.getBoundingClientRect().left - 16,
behavior: 'smooth',
})
}
return (
<div ref={outerRef} className={wrapperClassName}>
<div ref={rowRef} aria-label={label} className={className}>
{children}
</div>
{count > 1 ? (
<div className="mt-[2px] flex items-center gap-[7px]">
{Array.from({ length: count }, (_, position) => (
<button
key={position}
type="button"
aria-label={`Показать карточку ${position + 1} из ${count}`}
aria-current={position === index}
onClick={() => goTo(position)}
className="flex h-[22px] cursor-pointer items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-teal"
>
<span
className={cx(
'block h-[4px] rounded-full transition-[width,background-color] duration-300',
position === index ? 'w-[28px]' : 'w-[10px]',
tone === 'dark'
? position === index
? 'bg-teal-bright'
: 'bg-white/[0.22]'
: position === index
? 'bg-teal'
: 'bg-navy/[0.16]',
)}
/>
</button>
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,44 @@
import { ctaChecks } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { CheckIcon } from './Icons'
import { Container, Eyebrow, SectionLead } from './Layout'
import { LeadForm } from './LeadForm'
export function RequestSection() {
const copy = useReveal<HTMLDivElement>()
return (
<section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white">
{/* `grid-cols-[minmax(0,1fr)]` is load-bearing: an implicit `auto` track
takes its minimum from the widest thing inside it, so anything in the
form that cannot wrap sets the width of the whole section. */}
<Container className="relative grid grid-cols-[minmax(0,1fr)] items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
<div ref={copy.ref} className={cx('min-w-0', copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="text-[clamp(39px,6vw,68px)] compact:text-[32px] compact:tracking-[-0.035em]">
Запустим физиотерапию с нуля под ваш поток
</h2>
<SectionLead tone="dark">
Оставьте контакты специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего
подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
</SectionLead>
<ul className="mt-[25px] grid gap-[10px]">
{ctaChecks.map((check) => (
<li
key={check}
className="flex gap-[10px] text-[14px] text-white/[0.75] [&>svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright"
>
<CheckIcon />
<span>{check}</span>
</li>
))}
</ul>
</div>
<LeadForm />
</Container>
</section>
)
}
@@ -0,0 +1,53 @@
import skolkovo from '../assets/images/resident-skolkovo.svg'
import zhiguli from '../assets/images/resident-zhiguli.svg'
import { residencies } from '../data/content'
const logos = [zhiguli, skolkovo]
export function ResidencySection() {
return (
<section
aria-label="Резидентские статусы Экзо Групп"
className="residency-surface relative isolate overflow-hidden border-t border-white/[0.08] py-[34px] pb-[16px] text-white md:pt-[42px] md:pb-[20px]"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-[200px] -right-[120px] -z-1 size-[360px] rounded-full bg-teal/[0.16] blur-[80px]"
/>
<div className="mx-auto flex w-[min(calc(100%_-_32px),var(--container-page))] flex-col gap-[20px] md:w-[min(calc(100%_-_48px),var(--container-page))] md:flex-row md:items-center md:justify-between md:gap-[34px]">
<div className="grid max-w-[580px] gap-[6px]">
<span className="text-[10px] leading-[1.2] font-[850] tracking-[0.16em] text-teal">
ИННОВАЦИОННАЯ ЭКОСИСТЕМА
</span>
<strong className="text-[clamp(18px,2.6vw,27px)] leading-[1.15] tracking-[-0.02em] text-white">
Экзо Групп резидент ведущих технологических площадок
</strong>
</div>
<div className="grid w-[min(100%,540px)] grid-cols-2 gap-[11px] compact:grid-cols-1 md:flex-[0_0_min(50%,540px)]">
{residencies.map((residency, index) => (
<a
key={residency.href}
aria-label={residency.label}
className="flex min-h-[82px] min-w-0 items-center justify-center rounded-md border border-white/[0.75] bg-white/[0.97] px-[16px] py-[12px]
shadow-[0_16px_40px_rgb(0_0_0/0.13)] transition-[transform,box-shadow,border-color] duration-200
hover:-translate-y-[3px] hover:border-teal/[0.72] hover:shadow-[0_20px_46px_rgb(0_0_0/0.2)] compact:min-h-[78px]"
href={residency.href}
rel="noopener noreferrer"
target="_blank"
>
<img
alt={residency.label}
className="block h-auto max-h-[58px] w-auto max-w-full object-contain compact:max-h-[54px]"
src={logos[index]}
width={residency.width}
height={residency.height}
/>
</a>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,158 @@
import { useMemo, useState } from 'react'
import { calculatorDefaults, calculatorFields } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
const decimal = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
type FieldKey = (typeof calculatorFields)[number]['key']
/** Empty, negative and unparseable fields all read as "nothing entered yet". */
function toNumber(raw: string) {
const value = Number(raw.replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(value) && value > 0 ? value : 0
}
const EMPTY = '—'
/** The box every field and every result sits in; only the fill differs. */
const boxClass = 'min-w-0 rounded-[15px] border border-white/[0.13] p-[14px]'
export function RevenueCalculator() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
/**
* Strings, not numbers: a cleared field has to stay cleared while the reader
* types the next figure into it, and `Number('')` is 0 — indistinguishable
* from a deliberate zero.
*/
const [values, setValues] = useState<Record<FieldKey, string>>({
courses: String(calculatorDefaults.courses),
averageCheck: String(calculatorDefaults.averageCheck),
investment: String(calculatorDefaults.investment),
})
const results = useMemo(() => {
const courses = toNumber(values.courses)
const averageCheck = toNumber(values.averageCheck)
const investment = toNumber(values.investment)
const month = courses && averageCheck ? Math.round(courses * averageCheck) : 0
const year = month * 12
// Payback and ROI need both a monthly figure and an investment; with either
// missing the pair stays blank rather than reading as an instant return.
const payback = month && investment ? investment / month : 0
const roi = month && investment ? ((year - investment) / investment) * 100 : 0
return [
{
label: 'Дополнительная выручка / месяц',
value: month ? `${money.format(month)}` : EMPTY,
note: 'до вычета расходов',
accent: true,
},
{
label: 'Дополнительная выручка / год',
value: year ? `${money.format(year)}` : EMPTY,
note: 'при сохранении выбранной загрузки',
},
{
label: 'Срок возврата инвестиций',
value: payback ? `${decimal.format(payback)} мес.` : EMPTY,
note: 'инвестиции ÷ дополнительная выручка в месяц',
},
{
label: 'ROI за 12 месяцев',
value: roi ? `${money.format(Math.round(roi))}%` : EMPTY,
note: 'сценарный расчёт до операционных расходов',
},
]
}, [values])
return (
<div
ref={ref}
className={cx(
'calculator-surface mb-[32px] rounded-[22px] border border-[#39E0D2]/[0.34] p-[clamp(20px,2.5vw,28px)] shadow-[0_18px_46px_rgb(0_0_0/0.14)] compact:mb-[26px] compact:rounded-[18px] compact:p-[17px]',
revealClass,
)}
>
<div className="mb-[18px] grid items-end gap-[18px] lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)]">
<div>
<span className="mb-[6px] block text-[10px] font-extrabold tracking-[0.12em] text-[#39E0D2] uppercase">
Калькулятор сценария
</span>
<h3 className="mb-0 text-[clamp(20px,2.2vw,29px)] leading-[1.1] tracking-[-0.025em] text-white">
Посчитайте выручку и возврат инвестиций
</h3>
</div>
<p className="text-[13px] leading-[1.45] text-white/[0.61] lg:max-w-[420px] lg:text-right">
Три показателя и сразу видно месячную выручку, годовой результат, срок возврата и ROI.
</p>
</div>
<div className="mb-[12px] grid gap-[12px] sm:grid-cols-2 lg:grid-cols-3">
{calculatorFields.map((field, index) => (
<div
key={field.key}
className={cx(
boxClass,
'bg-navy-deep/[0.58]',
// Three fields across two columns leave the last one alone on its
// row; spanning it keeps the bottom edge straight.
index === calculatorFields.length - 1 && 'sm:col-span-full lg:col-span-1',
)}
>
<label htmlFor={field.id} className="mb-[7px] block text-[11px] leading-[1.3] font-[760] text-white/[0.78]">
{field.label}
</label>
<input
id={field.id}
type="number"
inputMode="numeric"
min={0}
step={field.step}
aria-label={field.ariaLabel}
value={values[field.key]}
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
className="h-[48px] w-full rounded-[11px] border border-[#39E0D2]/[0.42] bg-white px-[13px] text-[clamp(18px,2vw,24px)] font-extrabold text-[#071f35] tabular-nums outline-none transition-[border-color,box-shadow] duration-200 focus:border-[#39E0D2] focus:shadow-[0_0_0_3px_rgb(57_224_210/0.12)]"
/>
<small className="mt-[7px] block text-[10px] leading-[1.35] text-white/[0.45]">{field.hint}</small>
</div>
))}
</div>
<div aria-live="polite" className="grid gap-[12px] sm:grid-cols-2 lg:grid-cols-4">
{results.map((result) => (
<div
key={result.label}
className={cx(
boxClass,
'flex min-h-[108px] flex-col justify-between compact:min-h-[98px]',
result.accent ? 'calculator-result-accent border-[#39E0D2]/[0.48]' : 'calculator-result',
)}
>
<span className="text-[10px] leading-[1.3] font-[760] tracking-[0.035em] text-white/[0.63] uppercase">
{result.label}
</span>
<strong
className={cx(
'mt-[8px] mb-[4px] block text-[clamp(20px,2.2vw,29px)] leading-none tracking-[-0.03em] whitespace-nowrap tabular-nums compact:text-[clamp(23px,8vw,30px)]',
result.accent ? 'text-[#39E0D2]' : 'text-white',
)}
>
{result.value}
</strong>
<small className="text-[9.5px] leading-[1.3] text-white/[0.43]">{result.note}</small>
</div>
))}
</div>
<p className="mt-[13px] text-[11px] leading-[1.45] text-white/[0.52]">
<strong className="text-white/[0.78]">Как читать расчёт:</strong> выручка показывает потенциал продаж. Срок
возврата и ROI ориентиры до учёта ФОТ, аренды, расходников и налогов. На финальной модели эти расходы
добавляются отдельно.
</p>
</div>
)
}
@@ -0,0 +1,84 @@
import { revenueDrivers, revenueScenarios } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { RevenueCalculator } from './RevenueCalculator'
/**
* `.fin-model` paints its own band and, unlike every other section, keeps the
* 32px gutter at all widths — `.fin-model .container` outranks the 760px rule
* that widens the shared container to 48px. Hence the inline wrapper here
* rather than <Section>/<Container>.
*/
export function ScenariosSection() {
const cta = useReveal<HTMLDivElement>()
return (
<section id="financial-scenarios" className="bg-scenario py-[clamp(68px,8vw,110px)] text-white">
<div className="mx-auto w-[min(calc(100%_-_32px),var(--container-page))]">
<p className="mb-[14px] flex items-center gap-[10px] text-[12px] font-extrabold tracking-[0.12em] text-[#39E0D2] uppercase before:h-[2px] before:w-[26px] before:rounded-[2px] before:bg-teal before:content-['']">
Сценарии выручки
</p>
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em] compact:text-[29px] compact:tracking-[-0.03em]">
Даже умеренная загрузка кабинета даёт понятную выручку
</h2>
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72] compact:mb-[26px]">
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
срок окупаемости.
</p>
<div className="mb-[28px] grid grid-cols-[auto_1fr_auto] items-center gap-[18px] rounded-md border border-teal/[0.28] bg-white/[0.035] px-[24px] py-[20px] narrow:grid-cols-1 narrow:gap-[8px]">
<span className="text-[12px] font-extrabold tracking-[0.06em] text-teal-deep uppercase">Формула расчёта</span>
<div className="text-[clamp(18px,2vw,24px)] font-extrabold">оплаченные курсы × средний чек 27 000 </div>
<span className="text-right text-[12px] text-white/[0.55] narrow:text-left">без учёта расходов</span>
</div>
<div className="mb-[36px] grid grid-cols-3 gap-[18px] narrow:grid-cols-1">
{revenueScenarios.map((scenario) => (
<article
key={scenario.courses}
className="flex min-h-[190px] flex-col justify-between rounded-[24px] border border-teal/[0.26] bg-[#0e3b5b] px-[24px] py-[26px] text-white"
>
<div>
<strong className="block text-[clamp(34px,4vw,48px)] leading-none text-[#39E0D2]">
{scenario.courses}
</strong>
<small className="text-white/[0.58]">курсов в месяц</small>
</div>
<b className="text-[clamp(24px,3vw,36px)]">{scenario.revenue}</b>
<small className="text-white/[0.58]">{scenario.note}</small>
</article>
))}
</div>
<RevenueCalculator />
<h3 className="mb-[16px] text-[18px]">Что превращает расчёт в окупаемость</h3>
<div className="grid grid-cols-4 gap-[12px] narrow:grid-cols-2">
{revenueDrivers.map((driver) => (
<div
key={driver}
className="rounded-[14px] border border-teal/[0.48] px-[14px] py-[18px] text-center text-[14px] font-bold"
>
{driver}
</div>
))}
</div>
<p className="mt-[28px] rounded-[14px] border border-teal/[0.2] bg-white/[0.035] px-[20px] py-[18px] text-[13px] leading-[1.5] text-white/[0.58]">
На финальном расчёте считаем CAPEX, ФОТ, площадь, аренду, расходники, налоги. Здесь показана рамка выручки,
чтобы быстро понять порядок потенциала.
</p>
<div ref={cta.ref} className={cx('mt-[26px] flex justify-center compact:mt-[22px]', cta.revealClass)}>
<ButtonLink href="#proposal" className="min-w-[270px] compact:w-full compact:min-w-0">
<span>Как получить выручку?</span>
<ArrowRightIcon />
</ButtonLink>
</div>
</div>
</section>
)
}
@@ -0,0 +1,29 @@
import logo from '../assets/images/exo-logo.png'
import { footerLegal } from '../data/content'
import { Container } from './Layout'
export function SiteFooter() {
return (
/* The extra bottom padding below 980px is the landing strip for <ThumbBar />. */
<footer className="bg-footer py-[26px] pb-[96px] text-[12px] text-white/[0.52] lg:pb-[26px]">
<Container className="grid gap-[14px] md:grid-cols-[220px_1fr]">
<div className="flex max-w-[180px] shrink-0 items-center">
<img
alt="Экзо Групп — российские технологии реабилитации"
className="block h-auto w-[clamp(150px,16vw,180px)] max-w-[min(50vw,180px)] object-contain compact:w-[150px] compact:max-w-[48vw]"
src={logo}
width={900}
height={204}
/>
</div>
<div className="max-w-[900px] leading-[1.55]">{footerLegal}</div>
<div className="flex flex-wrap justify-between gap-[10px] border-t border-white/[0.09] pt-[13px] md:col-span-full">
<span>© 2026 Экзо Групп</span>
<span>Российские технологии реабилитации</span>
</div>
</Container>
</footer>
)
}
@@ -0,0 +1,67 @@
import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content'
import { useScrolled } from '../hooks/useScrollState'
import { cx } from '../lib/cx'
import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons'
import { Container } from './Layout'
/**
* Transparent over the hero; gains the blurred bar once the page scrolls.
*
* Below 980px it carries a logo and a call button and nothing else. The nav is
* not shown at that width and the proposal button has moved to <ThumbBar />,
* so there is nothing left up here to compete with the page.
*/
export function SiteHeader() {
const scrolled = useScrolled()
return (
<header
className={cx(
'fixed inset-x-0 top-0 z-[60] py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250 phone:py-[9px]',
scrolled && 'bg-navy-deep/[0.88] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
)}
>
<Container className="flex items-center justify-between gap-[18px]">
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — наверх">
<img
alt="Экзо Групп — российские технологии реабилитации"
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left phone:w-[168px]"
src={logo}
width={900}
height={204}
/>
</a>
<nav
aria-label="Основная навигация"
className="hidden items-center gap-[28px] text-[14px] font-[680] text-white/[0.76] lg:flex"
>
{navLinks.map((link) => (
<a key={link.href} href={link.href} className="transition-colors duration-200 hover:text-white">
{link.label}
</a>
))}
</nav>
<div className="flex shrink-0 items-center gap-[10px] phone:gap-[6px]">
<a
href={contacts.phoneHref}
aria-label="Позвонить в Экзо Групп"
className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
>
<PhoneIcon />
<span className="phone:hidden">Звонок</span>
</a>
{/* `max-lg:hidden`, not `hidden lg:inline-flex`: `inline-flex` from
the button base is emitted after `hidden` in the utility layer and
would win at every width. */}
<ButtonLink href="#proposal" className="max-lg:hidden" size={headerSize}>
Получить предложение
</ButtonLink>
</div>
</Container>
</header>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from 'react'
import { contacts, sectionNav } from '../data/content'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { cx } from '../lib/cx'
import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons'
const hrefs = sectionNav.map((section) => section.href)
/**
* Wayfinding and the one call to action, moved to the thumb — below 980px,
* which is exactly where the header nav stops being rendered.
*
* The page runs to seventeen screens on a phone. What a reader needs there is
* "where am I" and "how do I ask", and until now the top of the screen carried
* neither: it carried a logo and a button, pinned over the content the whole
* way down. The header gives that width back to the page; this takes over.
*/
export function ThumbBar() {
const { index, progress } = useReadingPosition(hrefs)
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const current = sectionNav[index]
/* Hidden over the hero, which has its own call to action, and again over the
form, where the bar would sit on top of the submit button. */
const visible = index > 0 && current.href !== '#proposal'
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
const { overflow } = document.body.style
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = overflow
window.removeEventListener('keydown', onKeyDown)
}
}, [open])
const close = () => {
setOpen(false)
triggerRef.current?.focus()
}
return (
<>
<div
className={cx(
'fixed inset-x-0 bottom-0 z-70 transition-[transform,opacity] duration-300 lg:hidden',
visible ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-full opacity-0',
)}
>
<div className="border-t border-white/[0.1] bg-navy-deep/[0.93] backdrop-blur-[20px]">
<div aria-hidden="true" className="h-[2px] bg-white/[0.08]">
<i className="block h-full bg-teal transition-[width] duration-150" style={{ width: `${progress * 100}%` }} />
</div>
<div className="mx-auto flex w-[min(calc(100%_-_24px),var(--container-page))] items-center gap-[10px] py-[10px]">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-[10px] rounded-[16px] border border-white/[0.14] bg-white/[0.06]
px-[13px] py-[8px] text-left transition-[background-color,border-color] duration-200 outline-none
hover:bg-white/[0.1] focus-visible:border-teal focus-visible:ring-2 focus-visible:ring-teal/[0.4]"
>
<span className="min-w-0">
<span className="block text-[10px] leading-[1.4] font-[850] tracking-[0.13em] text-teal uppercase">
Раздел {index + 1} / {sectionNav.length}
</span>
<span className="block truncate text-[14px] font-[760] text-white">{current.label}</span>
</span>
<ChevronUpIcon className="ml-auto size-[18px] shrink-0 text-white/[0.5]" />
</button>
<a
href="#proposal"
className="inline-flex min-h-[46px] shrink-0 cursor-pointer items-center rounded-full bg-teal px-[18px] text-[14px]
font-[790] text-navy shadow-[0_12px_32px_rgb(0_196_180/0.28)] transition-colors duration-200 hover:bg-teal-bright"
>
Получить аудит
</a>
</div>
</div>
</div>
{open ? (
<div className="fixed inset-0 z-80 lg:hidden" role="dialog" aria-modal="true" aria-label="Разделы страницы">
<button
type="button"
aria-label="Закрыть список разделов"
onClick={close}
className="veil-in absolute inset-0 size-full cursor-pointer bg-navy-deep/[0.66] backdrop-blur-[3px]"
/>
<div
className="sheet-in absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-[26px] border-t border-white/[0.12]
bg-navy-deep px-[16px] pt-[12px] pb-[20px] shadow-[0_-24px_60px_rgb(3_20_34/0.5)]"
>
<div className="mb-[14px] flex items-center justify-between">
<span className="text-[11px] font-[850] tracking-[0.14em] text-teal uppercase">Разделы страницы</span>
<button
type="button"
onClick={close}
aria-label="Закрыть"
className="grid size-[34px] cursor-pointer place-items-center rounded-full border border-white/[0.14] text-white/[0.6]
transition-colors duration-200 hover:text-white"
>
<CloseIcon className="size-[16px]" />
</button>
</div>
<nav className="grid gap-[2px]">
{sectionNav.map((section, position) => (
<a
key={section.href}
href={section.href}
onClick={close}
aria-current={position === index ? 'true' : undefined}
className={cx(
'flex items-center gap-[14px] rounded-[14px] px-[13px] py-[12px] text-[15px] transition-colors duration-200',
position === index ? 'bg-white/[0.09] font-[780] text-white' : 'text-white/[0.66] hover:text-white',
)}
>
<span
className={cx(
'w-[18px] shrink-0 text-[11px] font-[820] tabular-nums',
position === index ? 'text-teal' : 'text-white/[0.34]',
)}
>
{String(position + 1).padStart(2, '0')}
</span>
{section.label}
</a>
))}
</nav>
<a
href={contacts.phoneHref}
className="mt-[14px] flex min-h-[52px] items-center justify-center gap-[10px] rounded-[16px] border border-white/[0.16]
bg-white/[0.07] text-[15px] font-[760] text-white transition-colors duration-200 hover:bg-white/[0.12]
[&>svg]:size-[18px]"
>
<PhoneIcon />
{contacts.phone}
</a>
</div>
</div>
) : null}
</>
)
}
@@ -0,0 +1,82 @@
import productionSite from '../assets/images/production-site.webp'
import { companyStats, companyStatsNote, photoCaption, whyItems } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { whyIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function WhyExoSection() {
const photo = useReveal<HTMLDivElement>()
const list = useReveal<HTMLDivElement>()
const stats = useReveal<HTMLDivElement>()
return (
<Section id="why-exo">
<Container>
<SectionHead
eyebrow="Почему Экзо Групп"
title="Производство, клиническая практика и запуск — в одном цикле"
lead="Мы отвечаем за переход от оборудования к работающему направлению: технологии, методики, обучение, сервис, цифровой контроль и масштабирование."
/>
<div className="grid min-w-0 items-stretch gap-[24px] md:grid-cols-[minmax(360px,0.9fr)_minmax(0,1.1fr)]">
<div
ref={photo.ref}
className={cx(
'relative min-h-[390px] overflow-hidden rounded-xl shadow-deep',
"after:absolute after:inset-0 after:bg-[linear-gradient(0deg,rgb(3_24_39/0.68),transparent_56%)] after:content-['']",
photo.revealClass,
)}
>
<img
alt="Производственная площадка Экзо Групп в Тольятти"
className="size-full object-cover"
loading="lazy"
src={productionSite}
/>
<div className="absolute inset-x-[23px] bottom-[21px] z-1 text-white">
<strong className="block text-[22px]">{photoCaption.title}</strong>
<span className="block text-[13px] text-white/[0.67]">{photoCaption.text}</span>
</div>
</div>
<div className="min-w-0">
<Rail
outerRef={list.ref}
wrapperClassName={list.revealClass}
label="Почему Экзо Групп"
className="scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still"
>
{whyItems.map((item) => (
<div
key={item.title}
className="grid grid-cols-[44px_1fr] gap-[14px] rounded-[19px] border border-navy/[0.12] bg-white p-[18px]"
>
<span className="grid size-[44px] place-items-center rounded-[14px] bg-ice text-teal-ink [&>svg]:size-[22px]">
{whyIcons[item.icon]}
</span>
<div>
<strong className="mb-[4px] block text-[16px] text-navy">{item.title}</strong>
<span className="block text-[13px] text-muted">{item.text}</span>
</div>
</div>
))}
</Rail>
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] md:grid-cols-4', stats.revealClass)}>
{companyStats.map((stat) => (
<div key={stat.label} className="rounded-md border border-navy/[0.12] bg-white p-[18px]">
<strong className="block text-[30px] leading-none tracking-[-0.045em] text-navy">{stat.value}</strong>
<span className="mt-[6px] block text-[12px] text-muted">{stat.label}</span>
</div>
))}
</div>
<SmallNote className="mt-[11px]">{companyStatsNote}</SmallNote>
</div>
</div>
</Container>
</Section>
)
}