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>
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
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 |
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: 'млн пациентов медицинской реабилитации в России, 2020–2024',
|
||||
source: 'BusinesStat',
|
||||
},
|
||||
{
|
||||
value: '5,1%',
|
||||
label: 'прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 2026–2033',
|
||||
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: '1–2 неделя.',
|
||||
title: 'Сбор данных и предпроектный анализ потенциала',
|
||||
lead: 'Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы.',
|
||||
items: [
|
||||
{
|
||||
title: 'Анализ действующей базы и потока',
|
||||
text: 'Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.',
|
||||
},
|
||||
{
|
||||
title: 'Аудит помещений и эргономики',
|
||||
text: 'Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.',
|
||||
},
|
||||
{
|
||||
title: 'Экспертиза лицензирования',
|
||||
text: 'Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.',
|
||||
},
|
||||
{
|
||||
title: 'Оценка команды и выбор маршрутов',
|
||||
text: 'Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '3–5 неделя.',
|
||||
title: 'Разработка бизнес-проекта и EXO-интеграции',
|
||||
lead: 'Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ.',
|
||||
items: [
|
||||
{
|
||||
title: 'Создание концепции направления',
|
||||
text: 'Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.',
|
||||
},
|
||||
{
|
||||
title: 'Подбор оптимального состава',
|
||||
text: 'Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.',
|
||||
},
|
||||
{
|
||||
title: 'Проектирование планировки и сан-логистики',
|
||||
text: 'Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.',
|
||||
},
|
||||
{
|
||||
title: 'Юридическое сопровождение',
|
||||
text: 'Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.',
|
||||
},
|
||||
{
|
||||
title: 'Расчет экономики и плана пилота',
|
||||
text: 'Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '6–8 неделя.',
|
||||
title: 'Бесшовная подготовка, оснащение и обучение команды',
|
||||
lead: 'Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса.',
|
||||
items: [
|
||||
{
|
||||
title: 'Поставка и шеф-монтаж',
|
||||
text: 'Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.',
|
||||
},
|
||||
{
|
||||
title: 'Обучение врачей с нуля',
|
||||
text: 'Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.',
|
||||
},
|
||||
{
|
||||
title: 'Практика медсестер и администраторов',
|
||||
text: 'Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.',
|
||||
},
|
||||
{
|
||||
title: 'Внедрение процедурных карт',
|
||||
text: 'Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.',
|
||||
},
|
||||
{
|
||||
title: 'Настройка записи и расписания',
|
||||
text: 'Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '9–12 неделя.',
|
||||
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 },
|
||||
]
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cx(...values: (string | false | null | undefined)[]): string {
|
||||
return values.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -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>[]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
)
|
||||