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:
co-authored by
Claude Opus 5
parent
be7741e269
commit
0732aa2096
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 3–5 from someone still reading week 1–2, 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user