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
+38
View File
@@ -0,0 +1,38 @@
import { EquipmentSection } from './components/EquipmentSection'
import { Hero } from './components/Hero'
import { LaunchSection } from './components/LaunchSection'
import { MarketSection } from './components/MarketSection'
import { ProgramsSection } from './components/ProgramsSection'
import { ProjectSection } from './components/ProjectSection'
import { RequestSection } from './components/RequestSection'
import { ResidencySection } from './components/ResidencySection'
import { ScenariosSection } from './components/ScenariosSection'
import { SiteFooter } from './components/SiteFooter'
import { SiteHeader } from './components/SiteHeader'
import { ThumbBar } from './components/ThumbBar'
import { WhyExoSection } from './components/WhyExoSection'
export default function App() {
return (
<>
<SiteHeader />
<main>
<Hero />
<MarketSection />
<ScenariosSection />
<ProjectSection />
<ProgramsSection />
<EquipmentSection />
<LaunchSection />
<WhyExoSection />
<RequestSection />
</main>
<ResidencySection />
<SiteFooter />
<ThumbBar />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="330" height="88" viewBox="0 0 330 88" role="img" aria-label="Участник проекта Сколково">
<rect width="330" height="88" rx="16" fill="#ffffff"/>
<g transform="translate(16 19) scale(1.02)">
<path fill="#b1ec52" d="M12.31 0C5.51 0 0 5.51 0 12.31v24.62c0 6.8 5.51 12.31 12.31 12.31h24.62c6.8 0 12.31-5.51 12.31-12.31V0H12.31Z"/>
<path fill="#4d5759" d="M40.69 18.46h-5.91l-5.62 6.16V12.31H24.6v2.98c-.15-.19-.31-.38-.48-.57-1.66-1.76-4.05-2.65-7.11-2.65-3.53 0-5.54 1.58-6.61 2.91-1.32 1.65-1.91 3.9-1.5 5.73 1.06 4.74 5.46 5.5 8.69 5.92 2.55.33 4.99.8 4.93 3.07-.07 2.36-2.73 2.94-4.48 2.94-4.55 0-4.7-3.36-4.7-3.36H8.45c.07 1.26.45 3.29 2.01 5.03 1.7 1.91 4.26 2.87 7.59 2.87 2.56 0 4.89-.83 6.55-2.31v2.03h4.56v-5.81l1.61-1.77 4.61 7.58h5.31l-6.71-11.09 6.71-7.35ZM13.46 19.66c-.4-1.76 1.35-3.32 3.63-3.32 2.05 0 4.1.46 4.88 3.31h2.62v4.36c-3.27-2.69-10.37-1-11.13-4.35Z"/>
</g>
<text x="82" y="37" font-family="Arial, Helvetica, sans-serif" font-size="22" font-weight="700" letter-spacing="0.6" fill="#4d5759">УЧАСТНИК</text>
<text x="82" y="59" font-family="Arial, Helvetica, sans-serif" font-size="13" font-weight="600" letter-spacing="0.7" fill="#4d5759">ПРОЕКТА «СКОЛКОВО»</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

+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>
)
}
+504
View File
@@ -0,0 +1,504 @@
import deviceHydrogen from '../assets/images/device-hydrogen.webp'
import deviceImpulse from '../assets/images/device-impulse.webp'
import deviceLaser from '../assets/images/device-laser.webp'
import deviceMagnet from '../assets/images/device-magnet.webp'
import devicePress from '../assets/images/device-press.webp'
import deviceTecar from '../assets/images/device-tecar.webp'
import deviceTherapy from '../assets/images/device-therapy.webp'
export const contacts = {
phone: '+7 939 717-80-80',
phoneHref: 'tel:+79397178080',
}
export const navLinks = [
{ href: '#market', label: 'Рынок' },
{ href: '#financial-scenarios', label: 'Экономика' },
{ href: '#technology', label: 'Оборудование' },
{ href: '#launch', label: 'Запуск' },
]
/**
* Every section, in reading order — the list behind the thumb bar below 980px,
* where the header nav above is not shown. It is longer than `navLinks` on
* purpose: the four header links are a summary for a reader who can see the
* whole page at once, this is a map for one scrolling through seventeen
* screens of it.
*/
export const sectionNav = [
{ href: '#top', label: 'Начало' },
{ href: '#market', label: 'Рынок' },
{ href: '#financial-scenarios', label: 'Сценарии выручки' },
{ href: '#project', label: 'Что запускаем' },
{ href: '#programs', label: 'Курсовая модель' },
{ href: '#technology', label: 'Оборудование' },
{ href: '#launch', label: 'Запуск' },
{ href: '#why-exo', label: 'Почему Экзо' },
{ href: '#proposal', label: 'Заявка на аудит' },
]
/* -------------------------------------------------------------------------- */
/* Первый экран */
/* -------------------------------------------------------------------------- */
export const heroFacts = [
{ value: 'от 12 м²', label: 'стартовый формат одного кабинета' },
{ value: '90 дней', label: 'до контрольной точки пилота' },
{
value: '35% к чистой прибыли',
label: 'превращаем кабинет физиотерапии в главный источник дохода клиники',
},
]
/* -------------------------------------------------------------------------- */
/* Рыночный контекст */
/* -------------------------------------------------------------------------- */
export const marketStats = [
{
value: '2,41',
label: 'млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация',
source: 'WHO / IHME',
},
{ value: '+63%', label: 'рост глобальной потребности в реабилитации с 1990 года', source: 'WHO / IHME' },
{
value: '1,0 → 1,4',
label: 'млн пациентов медицинской реабилитации в России, 20202024',
source: 'BusinesStat',
},
{
value: '5,1%',
label: 'прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 20262033',
source: 'Grand View Research',
},
]
/** «Пока физиотерапии нет» — левая колонка карточки маршрута. */
export const routeWithout = [
'пациент ищет процедуры в другом месте;',
'у врача нет аппаратного этапа комплексной программы;',
'нет отдельной линейки курсов и пакетов;',
'часть повторных визитов и выручки остаётся вне центра.',
]
/** «После запуска направления» — правая, акцентная колонка. */
export const routeWith = [
'пациент проходит маршрут внутри одной клиники;',
'назначение, процедуры и контроль связаны в курс;',
'существующая база формирует стартовую загрузку;',
'руководитель видит конверсию, завершение и экономику.',
]
export const marketNotes = [
'Рыночные показатели приведены по WHO/IHME, BusinesStat и Grand View Research. Они описывают рынок и потребность, но не являются прогнозом загрузки конкретного медицинского центра.',
'CAGR (Compound Annual Growth Rate) — среднегодовой темп роста: показатель, который показывает, каким был бы постоянный средний процент роста рынка за каждый год выбранного периода.',
]
/* -------------------------------------------------------------------------- */
/* Сценарии выручки */
/* -------------------------------------------------------------------------- */
export const revenueScenarios = [
{ courses: '30', revenue: '0,81 млн ₽', note: 'осторожный сценарий' },
{ courses: '60', revenue: '1,62 млн ₽', note: 'рабочая загрузка' },
{ courses: '100', revenue: '2,70 млн ₽', note: 'сильная загрузка' },
]
export const revenueDrivers = ['Загрузка кабинета', 'Доля курсов', 'Повторные визиты', 'Средний чек']
/**
* The calculator opens on the middle scenario above — 60 courses at 27 000 ₽ —
* so its first reading reproduces the «1,62 млн ₽» already on the card next
* to it, and the reader changes numbers from a figure they have just seen.
*/
export const calculatorDefaults = {
courses: 60,
averageCheck: 27000,
investment: 5000000,
}
export const calculatorFields = [
{
key: 'courses' as const,
id: 'courseCalcCount',
label: 'Оплаченных курсов в месяц',
hint: 'Сколько курсов кабинет реально продаёт за месяц.',
ariaLabel: 'Количество оплаченных курсов в месяц',
step: 1,
},
{
key: 'averageCheck' as const,
id: 'courseCalcCheck',
label: 'Средний чек курса, ₽',
hint: 'Средняя стоимость одного оплаченного курса.',
ariaLabel: 'Средний чек курса в рублях',
step: 500,
},
{
key: 'investment' as const,
id: 'courseCalcInvestment',
label: 'Инвестиции в запуск, ₽',
hint: 'Стартовые вложения для расчёта срока возврата и ROI.',
ariaLabel: 'Инвестиции в запуск кабинета в рублях',
step: 100000,
},
]
/* -------------------------------------------------------------------------- */
/* Готовое направление */
/* -------------------------------------------------------------------------- */
export const promises = [
{
icon: 'retain',
title: 'Удержать пациента',
text: 'Добавить недостающий аппаратный этап в маршрут лечения и реабилитации внутри центра.',
},
{
icon: 'revenue',
title: 'Создать новую выручку',
text: 'Запустить отдельную платную линейку процедур, курсов и комплексных программ.',
},
{
icon: 'clinical',
title: 'Усилить врачебный продукт',
text: 'Связать назначение врача, технологии, длительность курса и контроль динамики.',
},
{
icon: 'metrics',
title: 'Управлять показателями',
text: 'Измерять назначения, загрузку, завершение курса, повторные визиты и экономику.',
},
] as const
export const systemStatement =
'Сначала выбирается клиническая задача. Затем — технология и формат кабинета.'
export const profileChipsNote = 'Профили, для которых особенно актуален предварительный аудит:'
export const profileChips = [
'Травматология и ортопедия',
'Неврология',
'Послеоперационная реабилитация',
'Спортивная медицина',
'Ревматология',
'Флебология / лимфология',
'Гинекология / Урология',
'Эстетическая медицина',
]
/* -------------------------------------------------------------------------- */
/* Технологическое ядро */
/* -------------------------------------------------------------------------- */
export const devices = [
{
image: deviceTherapy,
alt: 'Аппарат ЭкзоТерапия',
kicker: 'Системное усиление',
title: 'ЭкзоТерапия',
indications: 'ОДА • неврология • дыхательная система • сосуды • органы малого таза • ЖКТ',
role: 'Высокоинтенсивная импульсная магнитотерапия.',
},
{
image: deviceTecar,
alt: 'Аппарат ЭкзоТекар 3 в 1',
kicker: 'Локальное ядро',
title: 'ЭкзоТекар 3 в 1',
indications: 'мышцы • суставы • связки • сухожилия • фасции • рубцы • посттравматическое восстановление',
role: 'Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.',
},
{
image: deviceMagnet,
alt: 'Аппарат ЭкзоМагнит 3 в 1',
kicker: 'Многофакторный модуль',
title: 'ЭкзоМагнит 3 в 1',
indications: 'суставы • позвоночник • мышцы • сухожилия • мягкие ткани • малый таз',
role: 'Магнит высокой интенсивности + ударно-волновая терапия + инфракрасная терапия.',
},
{
image: devicePress,
alt: 'Аппарат ЭкзоПресс',
kicker: 'Параллельный поток',
title: 'ЭкзоПресс',
indications:
'отёчные программы • лимфоотток • снятие тяжести в ногах • сосуды • послеоперационная реабилитация',
role: 'Компрессионная терапия + тепловая терапия + холодовая терапия.',
},
{
image: deviceHydrogen,
alt: 'Аппарат ЭкзоВодород',
kicker: 'Поддерживающий контур',
title: 'ЭкзоВодород',
indications:
'системное восстановление • антиоксидантная терапия • улучшение метаболизма • поддержка иммунитета • антистресс',
role: 'Ингаляция молекулярным водородом',
},
{
image: deviceLaser,
alt: 'Аппарат ЭкзоЛазер B',
kicker: 'Локальные программы',
title: 'ЭкзоЛазер В',
indications:
'мягкотканные травмы • точечное воспаление • регенерация тканей • болевые синдромы • реабилитация • стимуляция микроциркуляции • противоотёчный эффект • лечение связок и сухожилий • ускорение метаболизма',
role: 'Высокоинтенсивная лазерная терапия.',
},
{
image: deviceImpulse,
alt: 'Аппарат ЭкзоИмпульс',
kicker: 'Нейромышечный модуль',
title: 'ЭкзоИмпульс',
indications:
'электромагнитная стимуляция • нервно-мышечная система • устранение спазмов и зажимов • активация регенерации • противовоспалительный эффект • микроциркуляция и лимфодренаж • снятие глубоких мышечных болей • восстановление двигательной активности • реабилитация при неврологических патологиях',
role: 'Фокусированная ударно-волновая терапия',
},
]
export const equipmentNote =
'Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.'
/* -------------------------------------------------------------------------- */
/* Курсовая модель */
/* -------------------------------------------------------------------------- */
export const programs = [
{
num: '01',
title: 'Нейрореабилитация',
text: 'Наше оборудование помогает пациентам восстановить двигательные функции, в т.ч. после инсульта, снизить проявления спастичности и парезов, улучшить баланс и координацию.',
},
{
num: '02',
title: 'Травматология и ортопедия',
text: 'Устройства обеспечивают эффективную реабилитацию после переломов и эндопротезирования, способствуют снятию отёка и боли, повышают подвижность суставов и предупреждают развитие контрактур.',
},
{
num: '03',
title: 'Послеоперационное восстановление',
text: 'Оборудование позволяет активизировать пациентов в ранние сроки, стимулирует регенерацию тканей и снижает риск послеоперационных осложнений.',
},
{
num: '04',
title: 'Хронические боли и дегенеративные заболевания',
text: 'При остеохондрозе и артрозах оборудование помогает снизить болевой синдром и улучшить трофику и подвижность поражённых участков.',
},
{
num: '05',
title: 'Детская реабилитация',
text: 'Специальные методики способствуют развитию моторных навыков у детей с ДЦП, укрепляют мышечный корсет и помогают освоить вертикальное положение.',
},
{
num: '06',
title: 'Спортивная медицина',
text: 'Оборудование ускоряет восстановление спортсменов после травм и перегрузок, помогает грамотно вернуться к тренировкам и соревнованиям.',
},
{
num: '07',
title: 'Паллиативная поддержка',
text: 'Устройства облегчают хроническую боль и помогают сохранить функциональную активность на максимально возможном уровне.',
},
]
export const routeSteps = [
{ title: 'Первичный приём', text: 'Цели, ограничения, противопоказания и исходные показатели.' },
{ title: 'Назначение курса', text: 'Выбор маршрута и технологий в пределах документации.' },
{ title: 'Процедуры', text: 'Выполнение по назначению, карте и утверждённому регламенту.' },
{
title: 'Контроль динамики',
text: 'Выбранные клинические показатели, переносимость и завершение курса.',
},
{ title: 'Повторный приём', text: 'Оценка результата, корректировка и следующий этап по показаниям.' },
]
/* -------------------------------------------------------------------------- */
/* Пространство и запуск */
/* -------------------------------------------------------------------------- */
export const formats = [
{
area: 'от 12 м²',
title: 'Один кабинет',
text: 'Стартовый формат под отдельный сценарий и ограниченную конфигурацию.',
},
{
area: 'от 35 м²',
title: 'Два кабинета',
text: 'Базовая инфраструктура с разделением активных и параллельных процедур.',
},
{
area: 'от 45 м²',
title: 'Три кабинета',
text: 'Расширенный формат для нескольких маршрутов и большей пропускной способности.',
},
]
export const formatNote =
'Финальный метраж, зонирование и состав фиксируются только после аудита помещения и медицинской модели.'
export const timeline = [
{
week: '12 неделя.',
title: 'Сбор данных и предпроектный анализ потенциала',
lead: 'Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы.',
items: [
{
title: 'Анализ действующей базы и потока',
text: 'Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.',
},
{
title: 'Аудит помещений и эргономики',
text: 'Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.',
},
{
title: 'Экспертиза лицензирования',
text: 'Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.',
},
{
title: 'Оценка команды и выбор маршрутов',
text: 'Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.',
},
],
},
{
week: '35 неделя.',
title: 'Разработка бизнес-проекта и EXO-интеграции',
lead: 'Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ.',
items: [
{
title: 'Создание концепции направления',
text: 'Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.',
},
{
title: 'Подбор оптимального состава',
text: 'Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.',
},
{
title: 'Проектирование планировки и сан-логистики',
text: 'Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.',
},
{
title: 'Юридическое сопровождение',
text: 'Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.',
},
{
title: 'Расчет экономики и плана пилота',
text: 'Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.',
},
],
},
{
week: '68 неделя.',
title: 'Бесшовная подготовка, оснащение и обучение команды',
lead: 'Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса.',
items: [
{
title: 'Поставка и шеф-монтаж',
text: 'Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.',
},
{
title: 'Обучение врачей с нуля',
text: 'Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.',
},
{
title: 'Практика медсестер и администраторов',
text: 'Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.',
},
{
title: 'Внедрение процедурных карт',
text: 'Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.',
},
{
title: 'Настройка записи и расписания',
text: 'Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.',
},
],
},
{
week: '912 неделя.',
title: 'Запуск пилотного проекта и выход на плановую прибыль',
lead: 'Тестовый запуск направления на вашей базе, контроль стандартов сервиса и получение первой коммерческой выручки.',
items: [
{
title: 'Запуск назначений и продаж',
text: 'Перевод первых целевых пациентов из вашей действующей базы на новые EXO-курсы вместо разовых визитов.',
},
{
title: 'Контроль медицинских и бизнес-показателей',
text: 'Сквозной мониторинг конверсии из приема в покупку курса, отслеживание динамики лечения пациентов по шкалам «до / после».',
},
{
title: 'Разбор отклонений и адаптация',
text: 'Оперативная корректировка работы персонала, разбор сложных клинических случаев и донастройка скриптов администраторов нашими экспертами.',
},
{
title: 'Внедрение премиального сервиса',
text: 'Контроль удовлетворенности пациентов для формирования лояльной базы и запуска сарафанного радио.',
},
],
},
]
/* -------------------------------------------------------------------------- */
/* Почему Экзо Групп */
/* -------------------------------------------------------------------------- */
export const whyItems = [
{
icon: 'production',
title: 'Собственная разработка и сервис',
text: 'Линейка высокоинтенсивных и восстановительных технологий внутри одного продуктового контура.',
},
{
icon: 'clinics',
title: 'Сеть Экзо Клиник',
text: 'Оборудование, маршруты и организация работы проверяются на реальных медицинских процессах.',
},
{
icon: 'shield',
title: 'Полный контур внедрения',
text: 'Проект, оборудование, клиническая логика, обучение, сервис, продажи, KPI и масштабирование.',
},
] as const
export const photoCaption = {
title: 'Российское производство',
text: 'Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».',
}
export const companyStats = [
{ value: '4', label: 'собственные клиники' },
{ value: '70+', label: 'партнёрских клиник' },
{ value: '60+', label: 'медицинских учреждений' },
{ value: '2 млн+', label: 'проведённых процедур' },
]
export const companyStatsNote =
'Количественные показатели — по корпоративным материалам Экзо Групп; перед массовой внешней публикацией рекомендуется финальная сверка.'
/* -------------------------------------------------------------------------- */
/* Заявка */
/* -------------------------------------------------------------------------- */
export const ctaChecks = [
'предварительный аудит базы и врачебных направлений;',
'варианты пространства и состава технологий;',
'план запуска на 90 дней и набор контрольных KPI.',
]
/* -------------------------------------------------------------------------- */
/* Подвал и резидентские статусы */
/* -------------------------------------------------------------------------- */
export const footerLegal =
'Материалы страницы предназначены для B2B-обсуждения проекта. Конкретный состав, медицинские показания, противопоказания, лицензирование, квалификация персонала, сроки и финансовая модель определяются после аудита центра и сверки с актуальной документацией медицинских изделий.'
export const residencies = [
{
href: 'https://isamara.ru/zhiguli-valley/',
label: 'Технопарк Жигулёвская долина',
width: 340,
height: 93,
},
{ href: 'https://sk.ru/', label: 'Участник проекта Сколково', width: 330, height: 88 },
]
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
/**
* For the few places where a phone needs different markup, not just different
* classes — the launch timeline, which becomes a set of disclosures rather
* than four full-height cards. `query` must be a literal, not a computed
* string.
*/
export function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false)
useEffect(() => {
const list = window.matchMedia(query)
const onChange = () => setMatches(list.matches)
onChange()
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
}, [query])
return matches
}
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
/**
* Which section the reader is currently in, plus how far through the page they
* are. Driven by scroll position rather than by an IntersectionObserver: every
* section here is taller than a phone viewport, so the question is not "is this
* visible" but "which one is under the reading line", and a threshold-based
* observer cannot answer that.
*
* `hrefs` must be a stable array — pass a module-level constant.
*/
export function useReadingPosition(hrefs: readonly string[]) {
const [index, setIndex] = useState(0)
const [progress, setProgress] = useState(0)
useEffect(() => {
let frame = 0
const measure = () => {
frame = 0
/* A third of the way down the screen: the line the eye actually reads
from, not the very top edge. */
const line = window.scrollY + window.innerHeight * 0.3
let current = 0
hrefs.forEach((href, position) => {
const element = document.querySelector(href)
if (element && element.getBoundingClientRect().top + window.scrollY <= line) current = position
})
const scrollable = document.documentElement.scrollHeight - window.innerHeight
setIndex(current)
setProgress(scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0)
}
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(measure)
}
measure()
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
if (frame) cancelAnimationFrame(frame)
}
}, [hrefs])
return { index, progress }
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react'
/**
* Scroll-in animation, matching the original page: fires once at 12% visibility
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
* that `prefers-reduced-motion` can neutralise it in one place.
*/
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element || visible) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
setVisible(true)
observer.unobserve(entry.target)
}
},
{ threshold: 0.12 },
)
observer.observe(element)
return () => observer.disconnect()
}, [visible])
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
}
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
/**
* The header gains its blurred background as soon as the page leaves the very
* top — 18px, same threshold as the original landing.
*/
export function useScrolled(threshold = 18) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [threshold])
return scrolled
}
+424
View File
@@ -0,0 +1,424 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
Design tokens — ported 1:1 from the :root block of the original landing.
The default Tailwind palette and breakpoints are cleared so that only the
EXO design system is reachable from utility classes.
--------------------------------------------------------------------------- */
@theme {
--color-*: initial;
--color-transparent: transparent;
--color-current: currentColor;
--color-white: #ffffff;
--color-black: #000000;
/* Brand */
--color-navy: #0a2540;
--color-navy-deep: #061b2f;
--color-navy-3: #0e3352;
--color-teal: #00c4b4;
--color-teal-bright: #45e4d7;
--color-teal-deep: #00a99a;
--color-teal-ink: #008f84;
--color-ice: #eafbf8;
/* Surfaces & text */
--color-paper: #f5fafb;
--color-ink: #0a2540;
--color-muted: #60778b;
--color-footer: #041524;
/* The dark "сценарии выручки" band sits between navy and navy-deep. */
--color-scenario: #071f35;
/* Radii */
--radius-md: 18px;
--radius-lg: 25px;
--radius-xl: 34px;
/* Elevation */
--shadow-deep: 0 30px 90px rgb(4 30 48 / 0.16);
--shadow-soft: 0 18px 55px rgb(3 26 43 / 0.08);
/* Layout rhythm */
--container-page: 1200px;
--spacing-section: clamp(68px, 8vw, 108px);
--spacing-section-compact: clamp(58px, 6vw, 84px);
/* Breakpoints — the original used 600 / 760 / 980 */
--breakpoint-*: initial;
--breakpoint-sm: 600px;
--breakpoint-md: 760px;
--breakpoint-lg: 980px;
/* "Inter Variable" is the family Fontsource registers; the rest of the chain
is the original stack, kept for the swap window and for the few glyphs
(≈ ×) that Inter's subsets do not carry. */
--font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif;
}
/* The original's `max-width` queries do not line up with the min-width scale
above; they are exposed as named variants instead of arbitrary values so the
intent stays readable in the markup. Declared widest-first: Tailwind emits
variants in registration order, so a narrower one must come later to win. */
@custom-variant stack (@media (max-width: 1050px));
@custom-variant narrow (@media (max-width: 760px));
@custom-variant compact (@media (max-width: 560px));
@custom-variant phone (@media (max-width: 520px));
@custom-variant tiny (@media (max-width: 430px));
@layer base {
html {
scroll-behavior: smooth;
background: var(--color-navy-deep);
}
body {
background-color: var(--color-paper);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* The fixed header is 78px tall; anchors must clear it. */
section[id] {
scroll-margin-top: 78px;
}
::selection {
background: rgb(0 196 180 / 0.28);
color: var(--color-navy);
}
/* Preflight zeroes the type scale; the original relied on these exact values
for every heading on the page, so they are restored once, here. */
h1,
h2,
h3 {
text-wrap: balance;
}
h1 {
margin-bottom: 22px;
max-width: 940px;
font-size: clamp(42px, 7.7vw, 84px);
font-weight: 790;
line-height: 0.98;
letter-spacing: -0.055em;
}
h2 {
margin-bottom: 20px;
font-size: clamp(34px, 5vw, 58px);
font-weight: 760;
line-height: 1.03;
letter-spacing: -0.045em;
}
h3 {
margin-bottom: 10px;
font-size: clamp(21px, 2.4vw, 28px);
font-weight: bold;
line-height: 1.14;
letter-spacing: -0.025em;
}
h4 {
font-weight: bold;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
}
@layer components {
/* Scroll-triggered entrance. A class rather than utilities because it is
applied to ~40 elements and toggled from a shared IntersectionObserver. */
.reveal {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.7s ease,
transform 0.7s cubic-bezier(0.2, 0.65, 0.3, 1);
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
/* Card rows that scroll horizontally on phones. Every section uses the same
mechanics and only differs in the card width and in the breakpoint where
the row becomes a plain grid — that part is applied per section with
`sm:grid-still` / `md:grid-still` and `auto-cols-*`. */
.scroll-cards {
display: grid;
grid-auto-flow: column;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
scrollbar-width: none;
padding: 2px 16px 15px;
margin-inline: -16px;
}
.scroll-cards::-webkit-scrollbar {
display: none;
}
.scroll-cards > * {
scroll-snap-align: start;
}
/* Hero backdrop: two stacked scrims. The horizontal one is pushed further
right from 980px up, where the layout gains room. */
.hero-scrim {
background:
linear-gradient(
90deg,
rgb(4 24 41 / 0.98) 0%,
rgb(5 28 47 / 0.92) 40%,
rgb(5 28 47 / 0.38) 76%,
rgb(5 28 47 / 0.14) 100%
),
linear-gradient(0deg, rgb(3 20 34 / 0.78), transparent 48%);
}
@media (min-width: 980px) {
.hero-scrim {
background:
linear-gradient(
90deg,
rgb(4 24 41 / 0.99) 0%,
rgb(5 28 47 / 0.93) 42%,
rgb(5 28 47 / 0.3) 74%,
rgb(5 28 47 / 0.1) 100%
),
linear-gradient(0deg, rgb(3 20 34 / 0.75), transparent 50%);
}
}
/* The hero photo. The slight upscale hides the edges the transform would
otherwise leave visible; the focal point moves right once the copy column
has room, so the equipment stays clear of the text. */
.hero-photo {
background-position: 65% center;
background-size: cover;
transform: scale(1.015);
}
@media (min-width: 980px) {
.hero-photo {
background-position: 72% center;
}
}
/* Soft teal blooms. The three on this page differ in both opacity and stop
position, so they cannot collapse into one class. */
.hero-bloom {
background: radial-gradient(circle, rgb(0 196 180 / 0.24), transparent 64%);
}
.teal-bloom {
background: radial-gradient(circle, rgb(0 196 180 / 0.22), transparent 67%);
}
.teal-bloom-soft {
background: radial-gradient(circle, rgb(0 196 180 / 0.17), transparent 68%);
}
.gap-card-surface {
background: linear-gradient(145deg, var(--color-navy), #0c3650);
}
.system-panel-surface {
background: linear-gradient(145deg, #ffffff, #f2fbfa);
}
/* Revenue calculator, inside the dark "сценарии выручки" band. */
.calculator-surface {
background: linear-gradient(135deg, rgb(0 196 180 / 0.08), rgb(255 255 255 / 0.03));
}
.calculator-result {
background: linear-gradient(145deg, rgb(14 59 91 / 0.96), rgb(7 31 53 / 0.96));
}
.calculator-result-accent {
background: linear-gradient(145deg, rgb(0 196 180 / 0.18), rgb(7 31 53 / 0.98));
}
.equipment-card-surface {
background: linear-gradient(155deg, rgb(255 255 255 / 0.1), rgb(255 255 255 / 0.045));
}
.equipment-media-surface {
background: radial-gradient(circle at 50% 42%, rgb(0 196 180 / 0.15), rgb(3 22 38 / 0.16) 46%, rgb(3 22 38 / 0.64) 100%);
}
.cta-surface {
background:
radial-gradient(circle at 20% 20%, rgb(0 196 180 / 0.18), transparent 33%),
radial-gradient(circle at 90% 100%, rgb(37 117 151 / 0.26), transparent 39%),
var(--color-navy-deep);
}
.residency-surface {
background: linear-gradient(180deg, #071d31 0%, #041524 100%);
}
}
/* ---------------------------------------------------------------------------
Phone.
The scale above is a display scale. At 375px every level of it collapses
onto the same note — a 42px h1 and a 34px h2 set at display tracking, one
after another for seventeen screens, all shouting equally. Below 560px the
whole page steps down half a level and the tracking opens back up (-0.055em
closes Cyrillic counters at 38px in a way it never does at 84px), so the
figures the argument actually rests on — 2,41, +63%, 2,70 млн ₽ — are left
as the loudest thing on the page.
--------------------------------------------------------------------------- */
@media (max-width: 560px) {
:root {
--spacing-section: 54px;
--spacing-section-compact: 46px;
}
/* The phone header is 62px tall, not 78px. */
section[id] {
scroll-margin-top: 66px;
}
h1 {
margin-bottom: 18px;
font-size: 38px;
line-height: 1;
letter-spacing: -0.035em;
/* `balance` is for two or three lines. Over five it starts trimming lines
to lengths it likes and leaves a ragged right edge instead of a filled
column. */
text-wrap: normal;
}
h2 {
margin-bottom: 14px;
font-size: 29px;
line-height: 1.06;
letter-spacing: -0.03em;
}
h3 {
font-size: 19px;
letter-spacing: -0.02em;
}
}
@layer components {
/* The hero scrim runs left-to-right, which on a 375px screen means it runs
across the whole photo: the image is paid for and then buried. On phones
it turns vertical instead, so the equipment reads across the top third and
the copy sits on solid ground below it. */
@media (max-width: 560px) {
.hero-scrim {
background: linear-gradient(
180deg,
/* The header is transparent over the hero and its logo is white; the
wave wall behind it is cream. This first stop is what keeps it
readable. */ rgb(4 24 41 / 0.82) 0%,
rgb(4 24 41 / 0.3) 12%,
rgb(4 24 41 / 0.34) 22%,
rgb(4 24 41 / 0.62) 34%,
rgb(5 28 47 / 0.93) 54%,
rgb(4 22 37 / 0.99) 100%
);
}
/* At 375px `cover` fits the photo by height, so the only part left above
the copy is the strip the room happens to put at the top — ceiling
tiles. Zooming past cover and dropping the frame lands the backlit wave
wall in that strip instead, which is the one thing in this photo worth
showing at 110px tall. */
.hero-photo {
background-size: auto 150%;
background-position: 55% 72%;
}
/* Room for the rail indicator that replaced the "swipe sideways" hints. */
.scroll-cards {
padding-bottom: 12px;
}
}
@keyframes sheet-in {
from {
transform: translateY(16px);
opacity: 0;
}
}
@keyframes veil-in {
from {
opacity: 0;
}
}
.sheet-in {
animation: sheet-in 0.26s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.veil-in {
animation: veil-in 0.2s ease;
}
}
/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
breakpoint prefix, because sections switch over at different widths. */
@utility grid-still {
grid-auto-flow: row;
grid-auto-columns: auto;
overflow: visible;
padding: 0;
margin-inline: 0;
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* Honeypot: must stay focusable-but-invisible, so `hidden` is not an option. */
@utility honeypot {
position: absolute !important;
left: -9999px !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
.reveal {
opacity: 1;
transform: none;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cx(...values: (string | false | null | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
+80
View File
@@ -0,0 +1,80 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
const ENDPOINT = '/api/leads/medical-centers-no-physio'
const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft'
const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of utmKeys) {
const value = params.get(key)
if (value) utm[key] = value
}
return utm
}
export interface SubmitResult {
ok: boolean
error?: string
fields?: Record<string, string>
}
export async function submitLead(
form: LeadFormId,
values: Omit<LeadInput, 'form' | 'utm' | 'page' | 'referrer'>,
): Promise<SubmitResult> {
const payload: LeadInput = {
...values,
form,
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = (await response.json().catch(() => null)) as LeadResponse | null
if (!response.ok || !body?.ok) {
// The page must never lose a lead just because the CRM is down.
saveDraft(payload)
return {
ok: false,
error: body && !body.ok ? body.error : FALLBACK_ERROR,
fields: body && !body.ok ? body.fields : undefined,
}
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_no_physio_lead_sent', form })
localStorage.removeItem(DRAFT_KEY)
return { ok: true }
} catch (error) {
saveDraft(payload)
console.warn('Lead endpoint error', error, payload)
return { ok: false, error: FALLBACK_ERROR }
}
}
function saveDraft(payload: LeadInput) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
} catch {
// Private-mode browsers throw on write; the visible error message is enough.
}
}
declare global {
interface Window {
dataLayer?: Record<string, unknown>[]
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Progressive `+7 900 000-00-00` mask, matching the original page's input
* handler. Returns the input untouched until the visitor has typed a digit, so
* that clearing the field is still possible.
*/
export function formatRuPhone(raw: string): string {
let digits = raw.replace(/\D/g, '').slice(0, 11)
if (!digits) return ''
if (digits[0] === '8') digits = `7${digits.slice(1)}`
if (digits[0] !== '7') digits = `7${digits}`
let value = '+7'
if (digits.length > 1) value += ` ${digits.slice(1, 4)}`
if (digits.length >= 5) value += ` ${digits.slice(4, 7)}`
if (digits.length >= 8) value += `-${digits.slice(7, 9)}`
if (digits.length >= 10) value += `-${digits.slice(9, 11)}`
return value
}
/** The original refused to submit before all eleven digits were entered. */
export const isCompleteRuPhone = (value: string) => value.replace(/\D/g, '').length >= 11
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Self-hosted so the landing stays self-contained and makes no third-party
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
// cyrillic are actually downloaded for this page.
import '@fontsource-variable/inter'
import App from './App'
import './index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root is missing from index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)