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,40 @@
|
||||
import { EquipmentSection } from './components/EquipmentSection'
|
||||
import { Hero } from './components/Hero'
|
||||
import { LaunchSection } from './components/LaunchSection'
|
||||
import { LossEconomySection } from './components/LossEconomySection'
|
||||
import { MobileActionBar } from './components/MobileActionBar'
|
||||
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 { WhyExoSection } from './components/WhyExoSection'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main>
|
||||
<Hero />
|
||||
<MarketSection />
|
||||
<LossEconomySection />
|
||||
<ScenariosSection />
|
||||
<ProjectSection />
|
||||
<EquipmentSection />
|
||||
<ProgramsSection />
|
||||
<LaunchSection />
|
||||
<WhyExoSection />
|
||||
<RequestSection />
|
||||
</main>
|
||||
|
||||
<ResidencySection />
|
||||
<SiteFooter />
|
||||
|
||||
<MobileActionBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
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,69 @@
|
||||
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 whitespace-nowrap ' +
|
||||
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
|
||||
'[&>svg]:size-[18px] [&>svg]:shrink-0'
|
||||
|
||||
/**
|
||||
* 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] 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,91 @@
|
||||
import { devices } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { CheckIcon } from './Icons'
|
||||
import { Container, ScrollHint, Section, SectionHead } from './Layout'
|
||||
|
||||
/**
|
||||
* Product shots differ in framing, so the last stylesheet pass in the original
|
||||
* sized three 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%] p-[2px] narrow:max-w-[86%] narrow:max-h-[86%]',
|
||||
}
|
||||
|
||||
const defaultFit = 'max-w-[86%] max-h-[86%] translate-y-[8px]'
|
||||
|
||||
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="Существующее оборудование + недостающие EXO-модули"
|
||||
lead="Для действующего кабинета не нужно покупать всё сразу. По итогам аудита выбираются платформы, которые закрывают конкретные клинические и операционные пробелы — без повторения уже имеющихся факторов."
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={track.ref}
|
||||
aria-label="Оборудование ЭКЗО Групп"
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<article
|
||||
key={device.title}
|
||||
className="equipment-card-surface flex min-h-[488px] flex-col overflow-hidden rounded-[26px] border border-white/[0.13]"
|
||||
>
|
||||
<div className="equipment-media-surface flex h-[300px] items-center justify-center overflow-hidden px-[18px] pt-[18px] pb-[14px]">
|
||||
<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-[16px] text-[13px] leading-[1.55] text-white/[0.72]">{device.indications}</p>
|
||||
<div className="mt-auto border-t border-white/[0.12] pt-[15px] text-[13px] leading-[1.55] font-[720] text-[#C7FFF9]">
|
||||
{device.role}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollHint tone="dark" className="md:hidden">
|
||||
На мобильном — листайте карточки в сторону
|
||||
</ScrollHint>
|
||||
|
||||
<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>
|
||||
Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая
|
||||
технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и
|
||||
формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.
|
||||
</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,77 @@
|
||||
import heroBg from '../assets/images/hero-bg.webp'
|
||||
import { heroFacts, heroFactsNote } 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 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="teal-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]">
|
||||
Проводим аудит оборудования, документы и загрузку, сохраняем полезные активы, убираем дублирование,
|
||||
добавляем только недостающие технологии и объединяем старое и новое в единые клинические маршруты.
|
||||
</p>
|
||||
|
||||
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] sm:flex-row sm:items-center">
|
||||
<ButtonLink href="#proposal">
|
||||
Получить предложение
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<ButtonLink href="#launch" variant="secondary">
|
||||
Посмотреть модели усиления
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label="Ключевые экономические показатели"
|
||||
className="grid max-w-[780px] grid-cols-1 gap-[10px] sm:grid-cols-3"
|
||||
>
|
||||
{heroFacts.map((fact) => (
|
||||
<div
|
||||
key={fact.value}
|
||||
className="min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]"
|
||||
>
|
||||
<strong className="mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]">
|
||||
{fact.value}
|
||||
</strong>
|
||||
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-[10px] max-w-[760px] text-[11px] leading-[1.45] text-white/[0.52]">{heroFactsNote}</p>
|
||||
|
||||
<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,180 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
|
||||
function Icon({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
|
||||
{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 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>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Экономика действующего кабинета */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Panel title: «Где вы теряете деньги уже сегодня». */
|
||||
export function WarningIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M12 3 2.8 19a1.4 1.4 0 0 0 1.2 2h16a1.4 1.4 0 0 0 1.2-2L12 3Z" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M12 8v5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
|
||||
/** Panel title: «Как мы превращаем потери в дополнительную выручку». */
|
||||
export function TrendUpIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M4 18 9 13l4 4 7-9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||||
<path d="M15 8h5v5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
|
||||
/** Left anchor of the leak diagram: the patient base. */
|
||||
export function PatientsIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<path
|
||||
d="M16 20v-1.5A4.5 4.5 0 0 0 11.5 14h-5A4.5 4.5 0 0 0 2 18.5V20"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
<circle cx="9" cy="7" r="4" stroke="currentColor" strokeWidth="1.7" />
|
||||
<path d="M17 10a3 3 0 1 0 0-6M22 20v-1.5a4.5 4.5 0 0 0-3.2-4.3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
|
||||
/** Right anchor of the leak diagram: what is left of the revenue. */
|
||||
export function RevenueBarsIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M4 19V9m6 10V5m6 14v-7m4 7V3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
|
||||
/** Closing statement: turn the base into steady growth. */
|
||||
export function GrowthBarsIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M5 19V9m5 10v-6m5 6V5m4 14V3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icons of the four «как превращаем потери» tiles, keyed by `leakSolutions[].icon`. */
|
||||
export const solutionIcons = {
|
||||
gear: (
|
||||
<Icon>
|
||||
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" strokeWidth="1.7" />
|
||||
<path
|
||||
d="m19.4 15 .6 1.1-2 3.4-1.3-.1a8.4 8.4 0 0 1-1.5.9l-.6 1.2h-4l-.6-1.2a8.4 8.4 0 0 1-1.5-.9l-1.3.1-2-3.4.6-1.1a8 8 0 0 1 0-2l-.6-1.1 2-3.4 1.3.1a8.4 8.4 0 0 1 1.5-.9l.6-1.2h4l.6 1.2a8.4 8.4 0 0 1 1.5.9l1.3-.1 2 3.4-.6 1.1a8 8 0 0 1 0 2Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</Icon>
|
||||
),
|
||||
clipboard: (
|
||||
<Icon>
|
||||
<path d="M8 4h8m-7-2h6a1 1 0 0 1 1 1v3H8V3a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.7" />
|
||||
<rect x="5" y="5" width="14" height="17" rx="2" stroke="currentColor" strokeWidth="1.7" />
|
||||
<path d="M9 11h6M9 15h6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
|
||||
</Icon>
|
||||
),
|
||||
repeat: (
|
||||
<Icon>
|
||||
<path d="M20 12a8 8 0 0 1-14.7 4.4M4 12A8 8 0 0 1 18.7 7.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
|
||||
<path d="M5 18v-4h4M19 6v4h-4" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
|
||||
</Icon>
|
||||
),
|
||||
layers: (
|
||||
<Icon>
|
||||
<path d="m12 3 8 4-8 4-8-4 8-4Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
|
||||
<path d="m4 12 8 4 8-4M4 17l8 4 8-4" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
|
||||
</Icon>
|
||||
),
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Модернизация и «почему Экзо» */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Icons of the four promise cards, keyed by `promises[].icon`. */
|
||||
export const promiseIcons = {
|
||||
load: (
|
||||
<Icon>
|
||||
<path d="M4 19V7l8-4 8 4v12H4Z M8 19v-6h8v6" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
),
|
||||
gap: (
|
||||
<Icon>
|
||||
<path d="M4 17 9 12l4 4 7-9 M20 7v5h-5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
),
|
||||
throughput: (
|
||||
<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>
|
||||
),
|
||||
result: (
|
||||
<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 = {
|
||||
audit: (
|
||||
<Icon>
|
||||
<path d="M4 17V7l8-4 8 4v10l-8 4-8-4Z M9 9h6v6H9z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
),
|
||||
clinic: (
|
||||
<Icon>
|
||||
<path d="M5 20v-8M12 20V4M19 20v-5M3 20h18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
|
||||
</Icon>
|
||||
),
|
||||
roles: (
|
||||
<Icon>
|
||||
<path d="M4 5h16v11H4zM8 20h8M12 16v4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||||
</Icon>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { formats, timeline } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
||||
|
||||
export function LaunchSection() {
|
||||
const grid = useReveal<HTMLDivElement>()
|
||||
const steps = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="launch" tone="white">
|
||||
<Container>
|
||||
<SectionHead
|
||||
split
|
||||
eyebrow="Три модели усиления"
|
||||
title="Можно начать точечно — без длительного закрытия кабинета"
|
||||
lead="Формат определяется после аудита оборудования, расписания и клинических пробелов. Расширение происходит только после того, как пилот подтверждает назначения, загрузку, результат и экономику."
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={grid.ref}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SmallNote className="mt-[16px]">
|
||||
Финальная конфигурация определяется по техническому состоянию аппаратов, документации, площади, лицензии,
|
||||
штату, потоку пациентов и текущей загрузке.
|
||||
</SmallNote>
|
||||
|
||||
<h3 className="mt-[30px] mb-[38px] text-[clamp(24px,3vw,34px)] font-[850] tracking-[-0.03em] text-navy">
|
||||
Пошаговый план модернизации кабинета за 8 недель
|
||||
</h3>
|
||||
|
||||
<div
|
||||
ref={steps.ref}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[14px] md:grid-still md:grid-cols-2', steps.revealClass)}
|
||||
>
|
||||
{timeline.map((step) => (
|
||||
<article key={step.week} className="rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[22px]">
|
||||
<span className="mb-[8px] block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">
|
||||
{step.week}
|
||||
</span>
|
||||
<strong className="mb-[10px] block text-[18px] leading-[1.2] text-navy">{step.title}</strong>
|
||||
<span className="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>
|
||||
{'text' in item && item.text ? `: ${item.text}` : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** `.scroll-hint` — only meaningful while the card row still scrolls sideways. */
|
||||
export function ScrollHint({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"mt-[12px] flex items-center gap-[8px] text-[11px] before:h-px before:w-[24px] before:bg-current before:content-['']",
|
||||
tone === 'dark' ? 'text-white/[0.48]' : 'text-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
'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,324 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
leakBreakdown,
|
||||
leakSolutions,
|
||||
leakStages,
|
||||
revenueFormula,
|
||||
revenueMetrics,
|
||||
revenueResult,
|
||||
} from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import {
|
||||
CheckIcon,
|
||||
GrowthBarsIcon,
|
||||
PatientsIcon,
|
||||
RevenueBarsIcon,
|
||||
TrendUpIcon,
|
||||
WarningIcon,
|
||||
solutionIcons,
|
||||
} from './Icons'
|
||||
import { Container, Eyebrow, Section, SectionLead } from './Layout'
|
||||
|
||||
export function LossEconomySection() {
|
||||
const head = useReveal<HTMLDivElement>()
|
||||
const loss = useReveal<HTMLElement>()
|
||||
const gain = useReveal<HTMLElement>()
|
||||
const closing = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="money-leak" tone="none" className="loss-section-surface overflow-hidden">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-[220px] left-[52%] size-[560px] rounded-full bg-[radial-gradient(circle,rgb(0_196_180/0.13),transparent_68%)]"
|
||||
/>
|
||||
|
||||
<Container className="relative z-1">
|
||||
<div ref={head.ref} className={cx('mb-[clamp(32px,4.5vw,54px)] max-w-[960px]', head.revealClass)}>
|
||||
<Eyebrow>Экономика действующего кабинета</Eyebrow>
|
||||
<h2 className="mb-[14px] text-[clamp(40px,5.7vw,68px)]">Деньги уходят мимо Вас</h2>
|
||||
<SectionLead className="max-w-[880px]">
|
||||
Вы уже вложились в кабинет и оборудование, но выручка не растёт. Проблема не в трафике — проблема в том, как
|
||||
используется текущая база.
|
||||
</SectionLead>
|
||||
</div>
|
||||
|
||||
<div className="grid items-stretch gap-[24px] stack:grid-cols-1 grid-cols-[minmax(0,1.03fr)_minmax(0,1fr)]">
|
||||
{/* ------------------------------------------------------------- */}
|
||||
{/* Где вы теряете деньги */}
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<article
|
||||
ref={loss.ref}
|
||||
className={cx(
|
||||
'loss-panel-surface overflow-hidden rounded-[30px] border border-teal-bright/[0.18] p-[30px] text-white shadow-[0_28px_80px_rgb(4_30_48/0.12)]',
|
||||
'narrow:rounded-[24px] narrow:px-[18px] narrow:py-[22px]',
|
||||
loss.revealClass,
|
||||
)}
|
||||
>
|
||||
<PanelTitle tone="dark">
|
||||
<WarningIcon />
|
||||
Где вы теряете деньги уже сегодня
|
||||
</PanelTitle>
|
||||
|
||||
<div className="grid items-end gap-[14px] narrow:grid-cols-1 narrow:items-stretch grid-cols-[102px_minmax(0,1fr)_102px]">
|
||||
<FlowAnchor value="1 000" label="активных пациентов в базе за год">
|
||||
<PatientsIcon />
|
||||
</FlowAnchor>
|
||||
|
||||
<div
|
||||
aria-label="Этапы потери выручки"
|
||||
className="relative grid min-w-0 grid-cols-4 gap-[6px] narrow:grid-cols-2 tiny:grid-cols-1"
|
||||
>
|
||||
{/* The pipe the leaks drip out of. Hidden once the stages wrap. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="loss-pipe absolute top-[99px] right-[5%] left-[5%] z-0 h-[13px] rounded-full stack:top-[93px] narrow:hidden"
|
||||
/>
|
||||
|
||||
{leakStages.map((stage) => (
|
||||
<div
|
||||
key={stage.percent}
|
||||
className="relative z-1 min-w-0 text-center narrow:rounded-[15px] narrow:border narrow:border-white/10 narrow:bg-white/[0.045] narrow:px-[10px] narrow:py-[13px]"
|
||||
>
|
||||
<div className="flex min-h-[42px] items-end justify-center text-[10px] leading-[1.2] text-white/[0.88] narrow:min-h-0 narrow:items-start narrow:text-[11px]">
|
||||
<span>
|
||||
{stage.label[0]}
|
||||
<br className="narrow:hidden" />{' '}
|
||||
{stage.label[1]}
|
||||
</span>
|
||||
</div>
|
||||
<strong className="my-[5px] mb-[7px] block text-[20px] leading-none font-[850] text-teal-bright narrow:mt-[8px] narrow:mb-0">
|
||||
{stage.percent}
|
||||
</strong>
|
||||
<div aria-hidden="true" className="loss-hole relative mx-auto mb-[30px] size-[31px] rounded-full narrow:hidden" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FlowAnchor value="≈200" label="пациентов доходят до выручки за год">
|
||||
<RevenueBarsIcon />
|
||||
</FlowAnchor>
|
||||
</div>
|
||||
|
||||
<div className="mt-[24px] grid gap-[14px] border-t border-white/[0.12] pt-[20px] narrow:grid-cols-1 grid-cols-[minmax(180px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="rounded-md border border-white/10 bg-white/[0.045] p-[18px]">
|
||||
<span className="block text-[10px] leading-[1.3] tracking-[0.04em] text-white/[0.55] uppercase">
|
||||
Сколько денег уходит мимо вас каждый год
|
||||
</span>
|
||||
<strong className="mt-[7px] mb-[5px] block text-[clamp(18px,1.95vw,26px)] leading-[1.02] tracking-[-0.04em] whitespace-nowrap tabular-nums text-white">
|
||||
≈ 20 000 000 ₽
|
||||
</strong>
|
||||
<small className="block text-[11px] leading-[1.4] text-white/[0.62]">
|
||||
потенциальной выручки теряется из-за неиспользованного потенциала текущей базы
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="grid content-center gap-[8px]">
|
||||
{leakBreakdown.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-[12px] border-b border-white/[0.09] pb-[7px] text-[10px] leading-[1.25] last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span className="text-white/[0.68]">{row.label}</span>
|
||||
<strong className="text-[12px] whitespace-nowrap text-white">{row.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
{/* Как превращаем потери в выручку */}
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<article
|
||||
ref={gain.ref}
|
||||
className={cx(
|
||||
'overflow-hidden rounded-[30px] border border-teal/[0.28] bg-white/[0.88] p-[30px] shadow-[0_28px_80px_rgb(4_30_48/0.12)] backdrop-blur-[12px]',
|
||||
'narrow:rounded-[24px] narrow:px-[18px] narrow:py-[22px]',
|
||||
gain.revealClass,
|
||||
)}
|
||||
>
|
||||
<PanelTitle tone="light">
|
||||
<TrendUpIcon />
|
||||
Как мы превращаем потери в дополнительную выручку
|
||||
</PanelTitle>
|
||||
|
||||
<div className="grid grid-cols-2 gap-[11px] narrow:grid-cols-1">
|
||||
{leakSolutions.map((solution) => (
|
||||
<div
|
||||
key={solution.title}
|
||||
className="loss-solution-surface grid min-h-[112px] grid-cols-[38px_1fr] items-start gap-[11px] rounded-[17px] border border-navy/[0.11] p-[16px]"
|
||||
>
|
||||
<span className="grid size-[38px] place-items-center rounded-[13px] bg-ice text-teal-ink [&>svg]:size-[21px]">
|
||||
{solutionIcons[solution.icon]}
|
||||
</span>
|
||||
<div>
|
||||
<strong className="mb-[4px] block text-[14px] leading-[1.2] text-navy">{solution.title}</strong>
|
||||
<span className="block text-[11px] leading-[1.38] text-muted">{solution.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-[22px] mb-[12px] text-[11px] font-[850] tracking-[0.06em] text-teal-ink uppercase">
|
||||
Результат: выручка растёт за счёт текущей базы
|
||||
</p>
|
||||
|
||||
<div
|
||||
aria-label="Сценарный расчёт годовой выручки"
|
||||
className="grid items-stretch gap-[7px] narrow:grid-cols-1 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto_minmax(0,0.8fr)_auto_minmax(0,1.35fr)]"
|
||||
>
|
||||
{revenueFormula.map((cell, index) => (
|
||||
<FormulaFragment key={cell.label} operator={index === 0 ? undefined : '×'} {...cell} />
|
||||
))}
|
||||
<FormulaFragment operator="=" result {...revenueResult} />
|
||||
</div>
|
||||
|
||||
<div className="mt-[13px] grid grid-cols-4 gap-[8px] rounded-[17px] border border-teal/[0.22] bg-[#EFFAF9] p-[14px] narrow:grid-cols-2 narrow:gap-[12px]">
|
||||
{revenueMetrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="min-w-0 border-r border-navy/[0.1] pr-[8px] last:border-r-0 last:pr-0
|
||||
narrow:border-r-0 narrow:border-b narrow:pr-0 narrow:pb-[10px] narrow:[&:nth-last-child(-n+2)]:border-b-0 narrow:[&:nth-last-child(-n+2)]:pb-0"
|
||||
>
|
||||
<strong className="block text-[14px] leading-[1.15] whitespace-nowrap tabular-nums text-teal-deep">
|
||||
{metric.value}
|
||||
</strong>
|
||||
<span className="mt-[4px] block text-[8.5px] leading-[1.3] text-muted">{metric.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={closing.ref}
|
||||
className={cx(
|
||||
'mt-[22px] grid items-center gap-[24px] rounded-[22px] border border-teal/[0.22] bg-white/90 px-[24px] py-[20px] shadow-soft',
|
||||
'narrow:grid-cols-1 narrow:p-[18px] tiny:rounded-md grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]',
|
||||
closing.revealClass,
|
||||
)}
|
||||
>
|
||||
<ClosingItem divider icon={<CheckIcon />}>
|
||||
<p className="text-[14px] leading-[1.45] text-navy">Мы не приводим новых пациентов.</p>
|
||||
<p className="mt-[3px] text-[14px] leading-[1.45] text-navy">
|
||||
Мы увеличиваем выручку с тех, кто уже у вас есть.
|
||||
</p>
|
||||
</ClosingItem>
|
||||
|
||||
<ClosingItem icon={<GrowthBarsIcon />}>
|
||||
<p className="text-[14px] leading-[1.45] text-navy">
|
||||
<strong className="font-[850]">Не теряйте деньги.</strong>
|
||||
<br />
|
||||
Превратите свою базу в источник стабильного роста.
|
||||
</p>
|
||||
</ClosingItem>
|
||||
</div>
|
||||
|
||||
<p className="mt-[14px] text-[11px] leading-[1.5] text-muted">
|
||||
Сценарная модель. Фактический потенциал рассчитывается после аудита базы, расписания, действующего
|
||||
оборудования, тарифов и структуры курсов.
|
||||
</p>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelTitle({ tone, children }: { tone: 'light' | 'dark'; children: ReactNode }) {
|
||||
return (
|
||||
<h3
|
||||
className={cx(
|
||||
'mb-[22px] flex items-center gap-[10px] text-[13px] leading-[1.25] font-[850] tracking-[0.09em] uppercase',
|
||||
'[&>svg]:size-[22px] [&>svg]:shrink-0 [&>svg]:text-teal tiny:text-[11px]',
|
||||
tone === 'dark' ? 'text-white' : 'text-navy',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
/** The patient-base / surviving-revenue blocks on either side of the leak pipe. */
|
||||
function FlowAnchor({ value, label, children }: { value: string; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="loss-anchor-surface flex min-h-[118px] flex-col items-center justify-center rounded-[17px] border border-teal-bright/[0.48] px-[10px] py-[15px] text-center
|
||||
narrow:grid narrow:min-h-0 narrow:grid-cols-[34px_auto_1fr] narrow:items-center narrow:justify-start narrow:gap-[10px] narrow:text-left
|
||||
[&>svg]:mb-[5px] [&>svg]:size-[28px] [&>svg]:text-teal-bright narrow:[&>svg]:m-0"
|
||||
>
|
||||
{children}
|
||||
<strong className="block text-[25px] leading-none font-[850] text-teal-bright narrow:text-[22px]">{value}</strong>
|
||||
<span className="mt-[7px] block text-[10px] leading-[1.3] text-white/[0.68] narrow:m-0">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One `× value` pair of the revenue formula — operator first, then the cell. */
|
||||
function FormulaFragment({
|
||||
operator,
|
||||
value,
|
||||
label,
|
||||
result = false,
|
||||
}: {
|
||||
operator?: string
|
||||
value: string
|
||||
label: string
|
||||
result?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{operator ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="self-center text-[21px] font-extrabold text-teal-ink narrow:justify-self-center narrow:rotate-90 narrow:leading-none"
|
||||
>
|
||||
{operator}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cx(
|
||||
'flex min-w-0 flex-col justify-center rounded-[15px] border px-[8px] py-[13px] text-center narrow:px-[12px] narrow:py-[15px]',
|
||||
result
|
||||
? 'loss-result-surface border-transparent shadow-[0_13px_30px_rgb(0_196_180/0.22)]'
|
||||
: 'border-teal/[0.28] bg-white',
|
||||
)}
|
||||
>
|
||||
<strong
|
||||
className={cx(
|
||||
'block leading-[1.05] tracking-[-0.03em] whitespace-nowrap tabular-nums',
|
||||
result ? 'text-[clamp(16px,1.35vw,20px)] text-[#062B3D]' : 'text-[clamp(16px,1.9vw,22px)] stack:text-[24px] text-navy',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</strong>
|
||||
<span className={cx('mt-[5px] block text-[9px] leading-[1.25]', result ? 'text-[#062B3D]' : 'text-muted')}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ClosingItem({
|
||||
icon,
|
||||
divider = false,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
divider?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'grid min-w-0 grid-cols-[44px_1fr] items-center gap-[14px]',
|
||||
divider && 'border-r border-navy/[0.12] pr-[24px] narrow:border-r-0 narrow:border-b narrow:pr-0 narrow:pb-[18px]',
|
||||
)}
|
||||
>
|
||||
<span className="grid size-[44px] place-items-center rounded-full border border-teal bg-ice text-teal-ink [&>svg]:size-[23px]">
|
||||
{icon}
|
||||
</span>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
cabinetAsProcedures,
|
||||
cabinetAsSystem,
|
||||
marketSources,
|
||||
marketStats,
|
||||
trendBars,
|
||||
trendChartLabel,
|
||||
} from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
|
||||
|
||||
export function MarketSection() {
|
||||
const stats = useReveal<HTMLDivElement>()
|
||||
const gap = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<Section id="market">
|
||||
<Container>
|
||||
<SectionHead
|
||||
split
|
||||
eyebrow="Рыночный контекст"
|
||||
title="Спрос на реабилитацию растёт. Кабинет должен расти вместе с ним"
|
||||
lead="Физиотерапия уже есть — точка роста не в новом запуске, а в максимальном использовании уже имеющейся площади, оборудования, команды и потока пациентов"
|
||||
/>
|
||||
|
||||
<div className="grid gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
|
||||
<div
|
||||
ref={stats.ref}
|
||||
className={cx(
|
||||
'scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:grid-cols-2',
|
||||
stats.revealClass,
|
||||
)}
|
||||
>
|
||||
{marketStats.map((stat) => (
|
||||
<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-[172px] 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 z-1 mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
|
||||
{stat.value}
|
||||
</strong>
|
||||
<p className="relative z-1 max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
|
||||
{/* In flow under `mt-auto`, not pinned to the card's bottom: the
|
||||
longest label wraps to four lines at phone card width and ran
|
||||
straight through an absolutely placed source line. */}
|
||||
<span className="relative z-1 mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
|
||||
{stat.source}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<article
|
||||
ref={gap.ref}
|
||||
className={cx(
|
||||
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep',
|
||||
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)]">
|
||||
Количество пациентов растёт. Конкурирует уже не наличие кабинета, а качество маршрута
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative z-1 mt-[4px] border-t border-white/[0.13] pt-[20px]">
|
||||
<div className="mb-[18px] flex items-end justify-between gap-[18px] phone:flex-col phone:items-start phone:gap-[4px]">
|
||||
<strong className="text-[18px] leading-[1.2]">Пациенты медицинской реабилитации по ОМС</strong>
|
||||
<span className="text-right text-[12px] text-white/[0.58] phone:text-left">млн человек · 2022–2025</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label={trendChartLabel}
|
||||
className="flex h-[196px] items-end gap-[10px] border-b border-l border-white/[0.15] px-[10px] pt-[14px] phone:h-[174px] phone:gap-[6px] phone:px-[6px]"
|
||||
>
|
||||
{trendBars.map((bar) => (
|
||||
<div key={bar.year} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-[8px]">
|
||||
<span className="text-[12px] font-extrabold whitespace-nowrap text-white phone:text-[11px]">
|
||||
{bar.value}
|
||||
</span>
|
||||
<div
|
||||
className="trend-bar min-h-[28px] w-[min(62px,72%)] rounded-t-[14px] rounded-b-[3px]"
|
||||
style={{ height: bar.height }}
|
||||
/>
|
||||
<span className="text-[12px] font-[760] text-white/[0.64]">{bar.year}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-1 grid gap-[12px] md:grid-cols-2">
|
||||
<CabinetColumn sign="—" title="Кабинет как набор процедур" items={cabinetAsProcedures} />
|
||||
<CabinetColumn sign="+" title="Кабинет как система" items={cabinetAsSystem} accent />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<SmallNote className="mt-[16px]">
|
||||
Источники рыночных показателей:{' '}
|
||||
{marketSources.map((source, index) => (
|
||||
<span key={source.href}>
|
||||
<a
|
||||
className="underline decoration-navy/[0.25] underline-offset-[3px]"
|
||||
href={source.href}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{source.label}
|
||||
</a>
|
||||
{index === marketSources.length - 1 ? '. ' : index === marketSources.length - 2 ? ' и ' : ', '}
|
||||
</span>
|
||||
))}
|
||||
Показатели описывают рынок и не прогнозируют загрузку конкретного центра.
|
||||
</SmallNote>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function CabinetColumn({
|
||||
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,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { contacts } from '../data/content'
|
||||
import { cx } from '../lib/cx'
|
||||
import { ButtonLink, buttonClass } from './Button'
|
||||
import { PhoneIcon } from './Icons'
|
||||
|
||||
/**
|
||||
* The primary CTA's home below 760px, where the header cannot hold it without
|
||||
* crushing the logo. See `SiteHeader`.
|
||||
*
|
||||
* It waits for the hero — whose own two buttons say the same thing — to scroll
|
||||
* away, and steps back down while the form is on screen, so it never sits on
|
||||
* top of the fields it points at. That also keeps it clear of the submit
|
||||
* button, which is the one control it could plausibly be mistaken for.
|
||||
*/
|
||||
export function MobileActionBar() {
|
||||
const [shown, setShown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hero = document.getElementById('top')
|
||||
const form = document.getElementById('proposal')
|
||||
if (!hero || !form) return
|
||||
|
||||
let heroOnScreen = true
|
||||
let formOnScreen = false
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === hero) heroOnScreen = entry.isIntersecting
|
||||
if (entry.target === form) formOnScreen = entry.isIntersecting
|
||||
}
|
||||
setShown(!heroOnScreen && !formOnScreen)
|
||||
})
|
||||
|
||||
observer.observe(hero)
|
||||
observer.observe(form)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
/* Not `hidden` when down: it is off-screen, and `inert` already takes it
|
||||
out of the tab order, so the slide stays animatable both ways. */
|
||||
inert={!shown || undefined}
|
||||
className={cx(
|
||||
'fixed inset-x-0 bottom-0 z-[55] border-t border-white/[0.1] bg-navy-deep/[0.94] backdrop-blur-[18px] md:hidden',
|
||||
'px-[16px] pt-[12px] pb-[max(12px,env(safe-area-inset-bottom))]',
|
||||
'transition-transform duration-300 ease-[cubic-bezier(0.2,0.65,0.3,1)]',
|
||||
shown ? 'translate-y-0' : 'translate-y-full',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<a
|
||||
href={contacts.phoneHref}
|
||||
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
|
||||
className={buttonClass('call', 'shrink-0', 'min-h-[52px] w-[52px] px-0')}
|
||||
>
|
||||
<PhoneIcon />
|
||||
</a>
|
||||
<ButtonLink href="#proposal" className="flex-1">
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { programs, routeSteps } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Eyebrow, ScrollHint, Section, SectionHead } from './Layout'
|
||||
|
||||
export function ProgramsSection() {
|
||||
const route = useReveal<HTMLElement>()
|
||||
|
||||
return (
|
||||
<Section id="programs" compact>
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Комбинированные маршруты"
|
||||
title="Старые и новые аппараты перестают быть отдельными услугами"
|
||||
lead="Действующие процедуры, ЛФК, массаж и EXO-технологии объединяются в курс под состояние пациента, этап реабилитации и утверждённую клиническую модель."
|
||||
/>
|
||||
|
||||
<div className="scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5">
|
||||
{programs.map((program) => (
|
||||
<ProgramCard key={program.num} {...program} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollHint className="md:hidden">На мобильном — листайте программы</ScrollHint>
|
||||
|
||||
<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-center 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="flex size-[42px] min-w-[42px] items-center justify-center rounded-[13px] bg-teal text-center leading-none font-black tabular-nums text-navy md:mb-[12px]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<strong className="mb-[2px] block text-[16px] leading-[1.18] [overflow-wrap:anywhere] [hyphens:auto]">
|
||||
{step.title}
|
||||
</strong>
|
||||
<span className="block text-[13px] leading-[1.42] text-white/[0.63] [overflow-wrap:anywhere] [hyphens:auto]">
|
||||
{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-[230px] rounded-[22px] border border-navy/[0.12] bg-white p-[23px] shadow-[0_15px_45px_rgb(4_30_48/0.055)] md:min-h-[255px]',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<span className="mb-[22px] block text-[13px] font-black tracking-[0.12em] text-[#00A99C]">{num}</span>
|
||||
<h3 className="text-[clamp(19px,1.75vw,23px)] leading-[1.12] [overflow-wrap:anywhere] [hyphens:auto]">{title}</h3>
|
||||
<p className="text-[14px] leading-[1.48] text-muted [overflow-wrap:anywhere] [hyphens:auto]">{text}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { profileChips, promises } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { promiseIcons } from './Icons'
|
||||
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
||||
|
||||
export function ProjectSection() {
|
||||
const strip = useReveal<HTMLDivElement>()
|
||||
const panel = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="project" tone="white">
|
||||
<Container>
|
||||
<SectionHead
|
||||
eyebrow="Модернизация без лишней закупки"
|
||||
title="Сохраняем полезное. Добавляем недостающее"
|
||||
lead="Мы начинаем не с аппаратов ЭКЗО, а с аудита уже сделанных инвестиций клиники: технических возможностей, документов, клинической роли, назначений, загрузки и экономики каждого аппарата."
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={strip.ref}
|
||||
className={cx(
|
||||
'scroll-cards mb-[24px] auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:mb-[28px] sm:grid-cols-2 md:grid-cols-4',
|
||||
strip.revealClass,
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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,
|
||||
)}
|
||||
>
|
||||
<div className="grid items-center gap-[18px] 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]">
|
||||
Инвестиция направляется не на замену всего текущего оборудования, а на закрытие конкретных клинических и
|
||||
операционных разрывов.
|
||||
</div>
|
||||
<div>
|
||||
<SmallNote className="mb-[10px]">Профили, для которых особенно актуальна модернизация:</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,39 @@
|
||||
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">
|
||||
<Container className="relative grid items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
|
||||
<div ref={copy.ref} className={cx(copy.revealClass)}>
|
||||
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
||||
<h2 className="text-[clamp(39px,6vw,68px)]">Усилим действующий кабинет — без лишнего дублирования</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,137 @@
|
||||
import { useState } from 'react'
|
||||
import { calculatorCopy, calculatorFields, calculatorResults } 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']
|
||||
|
||||
/**
|
||||
* Values are held as strings, not numbers: clearing a field has to leave it
|
||||
* empty while the visitor retypes, and coercing on every keystroke would snap
|
||||
* it back to 0. Anything that is not a positive finite number reads as 0 and
|
||||
* blanks the dependent results.
|
||||
*/
|
||||
const initialValues = Object.fromEntries(
|
||||
calculatorFields.map((field) => [field.key, String(field.initial)]),
|
||||
) as Record<FieldKey, string>
|
||||
|
||||
function toNumber(raw: string): number {
|
||||
const value = Number(raw.replace(/\s/g, '').replace(',', '.'))
|
||||
return Number.isFinite(value) && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
const rubles = (value: number) => (value ? `${money.format(value)} ₽` : '—')
|
||||
|
||||
export function RevenueCalculator() {
|
||||
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||
const [values, setValues] = useState(initialValues)
|
||||
|
||||
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
|
||||
const payback = month && investment ? investment / month : null
|
||||
const roi = month && investment ? ((year - investment) / investment) * 100 : null
|
||||
|
||||
const results: Record<(typeof calculatorResults)[number]['key'], string> = {
|
||||
month: rubles(month),
|
||||
year: rubles(year),
|
||||
payback: payback === null ? '—' : `${decimal.format(payback)} мес.`,
|
||||
roi: roi === null || !Number.isFinite(roi) ? '—' : `${money.format(Math.round(roi))}%`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'mb-[30px] rounded-[22px] border border-teal-bright/[0.34] p-[clamp(18px,2.2vw,26px)]',
|
||||
'bg-[linear-gradient(135deg,rgb(0_196_180/0.08),rgb(255_255_255/0.03))] shadow-[0_18px_46px_rgb(0_0_0/0.14)]',
|
||||
'narrow:rounded-[18px] narrow:p-[16px]',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<div className="mb-[17px] grid items-start gap-[18px] narrow:mb-[16px] narrow:gap-[8px] lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)] lg:items-end">
|
||||
<div>
|
||||
<span className="mb-[6px] block text-[10px] font-[850] tracking-[0.12em] text-teal-bright uppercase">
|
||||
{calculatorCopy.eyebrow}
|
||||
</span>
|
||||
<h3 className="text-[clamp(20px,2.15vw,28px)] leading-[1.1] tracking-[-0.025em] text-white narrow:text-[19px] narrow:leading-[1.15]">
|
||||
{calculatorCopy.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[13px] leading-[1.45] text-white/[0.61] narrow:text-[11.5px] narrow:leading-[1.4] lg:max-w-[430px] lg:text-right">
|
||||
{calculatorCopy.lead}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-[12px] grid gap-[12px] sm:grid-cols-2 lg:grid-cols-3">
|
||||
{calculatorFields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="min-w-0 rounded-[15px] border border-white/[0.13] bg-[rgb(5_28_47/0.58)] p-[14px] narrow:p-[12px] sm:last:col-span-2 lg:last:col-span-1"
|
||||
>
|
||||
<label
|
||||
htmlFor={`calc-${field.key}`}
|
||||
className="mb-[7px] block text-[11px] leading-[1.3] font-[760] text-white/[0.78] narrow:text-[10.5px]"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`calc-${field.key}`}
|
||||
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-[46px] w-full rounded-[11px] border border-teal-bright/[0.42] bg-white px-[13px] text-[clamp(17px,1.85vw,22px)] font-[850] tabular-nums text-[#071f35] outline-none
|
||||
transition-[border-color,box-shadow] duration-200 focus:border-teal-bright focus:shadow-[0_0_0_3px_rgb(69_228_215/0.12)] narrow:h-[44px] narrow:text-[18px]"
|
||||
/>
|
||||
<small className="mt-[7px] block text-[10px] leading-[1.35] text-white/[0.45] narrow:text-[9.5px] narrow:leading-[1.3]">
|
||||
{field.hint}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" className="grid gap-[12px] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{calculatorResults.map((result) => (
|
||||
<div
|
||||
key={result.key}
|
||||
className={cx(
|
||||
'flex min-h-[104px] min-w-0 flex-col justify-between rounded-[15px] border p-[14px] narrow:min-h-[84px] narrow:p-[12px]',
|
||||
result.accent
|
||||
? 'border-teal-bright/[0.48] bg-[linear-gradient(145deg,rgb(0_196_180/0.18),rgb(7_31_53/0.98))]'
|
||||
: 'border-white/[0.13] bg-[linear-gradient(145deg,rgb(14_59_91/0.96),rgb(7_31_53/0.96))]',
|
||||
)}
|
||||
>
|
||||
<span className="text-[9.5px] leading-[1.3] font-[760] tracking-[0.035em] text-white/[0.63] uppercase narrow:leading-[1.25]">
|
||||
{result.label}
|
||||
</span>
|
||||
<strong
|
||||
className={cx(
|
||||
'my-[8px] mb-[4px] block text-[clamp(18px,2vw,27px)] leading-none tracking-[-0.03em] whitespace-nowrap tabular-nums narrow:text-[20px]',
|
||||
result.accent ? 'text-teal-bright' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{results[result.key]}
|
||||
</strong>
|
||||
<small className="text-[9.5px] leading-[1.3] text-white/[0.43] narrow:text-[9px]">{result.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-[13px] text-[11px] leading-[1.45] text-white/[0.52] narrow:text-[10px] narrow:leading-[1.4]">
|
||||
<strong className="font-[850] text-white/[0.78]">Как читать расчёт:</strong> {calculatorCopy.caption}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { revenueDrivers, revenueFormulaCopy, 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'
|
||||
import { Container, Eyebrow, Section } from './Layout'
|
||||
|
||||
export function ScenariosSection() {
|
||||
const cta = useReveal<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<Section id="financial-scenarios" tone="none" className="bg-scenario text-white">
|
||||
<Container>
|
||||
<Eyebrow tone="dark" className="mb-[14px]">
|
||||
Сценарии выручки
|
||||
</Eyebrow>
|
||||
|
||||
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em]">
|
||||
Даже умеренная загрузка кабинета даёт понятную выручку
|
||||
</h2>
|
||||
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72]">
|
||||
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
|
||||
срок окупаемости.
|
||||
</p>
|
||||
|
||||
{/* The tag column is fixed at 170px so the copy beside it keeps one
|
||||
measure across the three paragraphs; below 760px it stacks. */}
|
||||
<div className="mb-[28px] grid items-start gap-[22px] rounded-md border border-teal/[0.28] bg-white/[0.035] px-[24px] py-[20px] narrow:grid-cols-1 narrow:gap-[8px] narrow:px-[14px] narrow:py-[14px] grid-cols-[170px_1fr]">
|
||||
<span className="text-[12px] font-extrabold tracking-[0.06em] text-teal-deep uppercase narrow:text-[10px] narrow:tracking-[0.055em]">
|
||||
{revenueFormulaCopy.tag}
|
||||
</span>
|
||||
<div className="text-[15px] leading-[1.55] text-white/[0.78] narrow:text-[12.2px] narrow:leading-[1.43] tiny:text-[11.7px] tiny:leading-[1.4] [&_strong]:font-extrabold [&_strong]:text-white narrow:[&_strong]:font-[780]">
|
||||
<p className="narrow:text-[12.6px] narrow:leading-[1.35] tiny:text-[12px]">
|
||||
<strong>{revenueFormulaCopy.headline}</strong>
|
||||
</p>
|
||||
<p className="mt-[6px] narrow:mt-[5px]">
|
||||
{revenueFormulaCopy.sourcePrefix}
|
||||
<strong>{revenueFormulaCopy.sourceAmount}</strong>
|
||||
{revenueFormulaCopy.sourceSuffix}
|
||||
</p>
|
||||
<p className="mt-[6px] narrow:mt-[5px]">
|
||||
<strong>{revenueFormulaCopy.caveat}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</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-lg border border-teal/[0.26] bg-navy-4 px-[24px] py-[26px] text-white"
|
||||
>
|
||||
<div>
|
||||
<strong className="block text-[clamp(34px,4vw,48px)] leading-none text-teal-bright">
|
||||
{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(
|
||||
'revenue-cta-surface mt-[24px] flex items-center justify-between gap-[20px] rounded-md border border-teal/[0.24] px-[20px] py-[18px]',
|
||||
'max-[700px]:flex-col max-[700px]:items-stretch',
|
||||
cta.revealClass,
|
||||
)}
|
||||
>
|
||||
<ButtonLink href="#proposal" className="min-w-[250px] shrink-0 max-[700px]:w-full max-[700px]:min-w-0">
|
||||
Как получить выручку?
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<span className="max-w-[560px] text-[13px] leading-[1.5] text-white/[0.62] max-[700px]:text-center">
|
||||
Перейдите к форме — подготовим расчёт потенциала для вашего кабинета.
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import logo from '../assets/images/exo-logo.png'
|
||||
import { Container } from './Layout'
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer
|
||||
/* Extra bottom room on phones: `MobileActionBar` is ~76px tall plus the
|
||||
safe-area inset, and it is still up at the foot of the page. */
|
||||
className="bg-footer pt-[26px] pb-[calc(102px+env(safe-area-inset-bottom))] text-[12px] text-white/[0.52] md: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]">
|
||||
Материалы страницы предназначены для B2B-обсуждения проекта модернизации. Решение по сохранению, ремонту,
|
||||
интеграции, замене или выводу оборудования принимается после технического и правового аудита. Конкретный
|
||||
состав, медицинские показания, противопоказания, лицензирование, квалификация персонала, сроки и финансовая
|
||||
модель определяются после сверки с актуальной документацией медицинских изделий.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-[10px] border-t border-white/[0.09] pt-[13px] md:col-span-full">
|
||||
<span>© {new Date().getFullYear()} Экзо Групп</span>
|
||||
<span>Российские технологии реабилитации</span>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import logo from '../assets/images/exo-logo.png'
|
||||
import { contacts, navLinks } from '../data/content'
|
||||
import { ButtonLink, buttonClass, headerSize } from './Button'
|
||||
import { PhoneIcon } from './Icons'
|
||||
import { Container } from './Layout'
|
||||
|
||||
/**
|
||||
* The original ended up with the blurred background painted unconditionally —
|
||||
* the last stylesheet override drops the scroll-dependent variant — so there is
|
||||
* no scroll state to track here.
|
||||
*
|
||||
* Below 760px the bar carries the brand mark and the call button only. The two
|
||||
* action pills used to be `shrink-0` and 288px wide inside a 343px container,
|
||||
* which left the logo — the one element that has to stay legible on a
|
||||
* credibility-led B2B page — 37px at iPhone width and 0px at 320px, with the
|
||||
* CTA itself running past the right edge. The CTA moves to `MobileActionBar`,
|
||||
* which has room for it and puts it in the thumb zone.
|
||||
*/
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-[60] bg-navy-deep/[0.92] py-[14px] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]">
|
||||
<Container className="flex items-center justify-between gap-[16px]">
|
||||
<a className="inline-flex shrink-0 items-center" href="#top" aria-label="ЭКЗО Групп — наверх">
|
||||
<img
|
||||
alt="ЭКЗО Групп — российские технологии реабилитации"
|
||||
className="block h-auto w-[146px] object-contain object-left compact:w-[138px] sm:w-[168px] lg:w-[clamp(172px,20vw,232px)]"
|
||||
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-[8px]">
|
||||
<a
|
||||
href={contacts.phoneHref}
|
||||
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
|
||||
className={buttonClass(
|
||||
'call',
|
||||
undefined,
|
||||
/* Square and icon-only until the bar has room for the word. */
|
||||
'min-h-[44px] w-[44px] px-0 text-[13px] md:w-auto md:px-[17px]',
|
||||
)}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span className="hidden md:inline">Звонок</span>
|
||||
</a>
|
||||
{/* `max-md`, not the `narrow` variant: `narrow` is max-width 760 and the
|
||||
bar is min-width 760, so at exactly 760px both would hide and the page
|
||||
would have no CTA at all. `max-md` is the exact complement of `md`. */}
|
||||
<ButtonLink href="#proposal" size={headerSize} className="max-md:hidden">
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import productionSite from '../assets/images/production-site.webp'
|
||||
import { companyStats, 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'
|
||||
|
||||
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="eager"
|
||||
src={productionSite}
|
||||
/>
|
||||
<div className="absolute inset-x-[23px] bottom-[21px] z-1 text-white">
|
||||
<strong className="block text-[22px]">Российское производство и сервис</strong>
|
||||
<span className="block text-[13px] text-white/[0.67]">
|
||||
Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
ref={list.ref}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[10px] md:grid-still', list.revealClass)}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] sm: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]">
|
||||
Количественные показатели — по корпоративным материалам Экзо Групп. Срок пилота ориентировочный и зависит
|
||||
от помещения, лицензии, поставки и обучения.
|
||||
</SmallNote>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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: 'Модели запуска' },
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Первый экран */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const heroFacts = [
|
||||
{ value: '0 ₽', label: 'дополнительных расходов на маркетинг — рост за счёт текущей базы пациентов' },
|
||||
{ value: '≈20 млн ₽', label: 'потенциальной выручки может уходить мимо клиники ежегодно' },
|
||||
{ value: 'от 6 мес.', label: 'срок окупаемости' },
|
||||
]
|
||||
|
||||
/** Disclaimer under the hero facts — they are a scenario, not a promise. */
|
||||
export const heroFactsNote =
|
||||
'Сценарные показатели. Финальная экономика рассчитывается после аудита кабинета, тарифов, загрузки и структуры курсов.'
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Рыночный контекст */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const marketStats = [
|
||||
{
|
||||
value: '2,41',
|
||||
label: 'млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация',
|
||||
source: 'WHO / IHME',
|
||||
},
|
||||
{ value: '+63%', label: 'рост глобальной потребности в реабилитации с 1990 года', source: 'WHO / IHME' },
|
||||
{
|
||||
value: '1,14 → 1,79',
|
||||
label: 'млн пациентов получили медицинскую реабилитацию по ОМС в России, 2022–2025',
|
||||
source: 'Минздрав России',
|
||||
},
|
||||
{
|
||||
value: '+26,5%',
|
||||
label: 'целевой рост числа получателей медицинской реабилитации к 2030 году',
|
||||
source: 'Федеральный проект',
|
||||
},
|
||||
]
|
||||
|
||||
/** Пациенты медицинской реабилитации по ОМС, млн человек. */
|
||||
export const trendBars = [
|
||||
{ year: '2022', value: '1,14', height: '64%' },
|
||||
{ year: '2023', value: '1,39', height: '78%' },
|
||||
{ year: '2024', value: '1,52', height: '85%' },
|
||||
{ year: '2025', value: '1,79', height: '100%' },
|
||||
]
|
||||
|
||||
export const trendChartLabel =
|
||||
'Рост числа пациентов медицинской реабилитации по ОМС в России с 1,14 миллиона в 2022 году до 1,79 миллиона в 2025 году'
|
||||
|
||||
export const cabinetAsProcedures = [
|
||||
'аппараты работают разрозненно;',
|
||||
'одна услуга за визит и слабая пакетизация;',
|
||||
'назначения зависят от одного специалиста;',
|
||||
'нет контроля загрузки и завершения курса.',
|
||||
]
|
||||
|
||||
export const cabinetAsSystem = [
|
||||
'существующие методы встроены в маршруты;',
|
||||
'EXO закрывает только выявленные пробелы;',
|
||||
'команда работает по единым картам и ролям;',
|
||||
'руководитель видит загрузку, результат и экономику.',
|
||||
]
|
||||
|
||||
export const marketSources = [
|
||||
{
|
||||
label: 'WHO / IHME',
|
||||
href: 'https://www.who.int/teams/noncommunicable-diseases/sensory-functions-disability-and-rehabilitation/global-estimates-of-the-need-for-rehabilitation',
|
||||
},
|
||||
{
|
||||
label: 'Минздрав России',
|
||||
href: 'https://minzdrav.gov.ru/news/2026/04/06/30625-zamglavy-minzdrava-rossii-evgeniy-kamkin-rasskazal-kak-izmenilas-sistema-meditsinskoy-reabilitatsii-v-rossii',
|
||||
},
|
||||
{
|
||||
label: 'федеральный проект медицинской реабилитации',
|
||||
href: 'https://minzdrav.gov.ru/poleznye-resursy/natsionalnye-proekty-rossii-prodolzhitelnaya-i-aktivnaya-zhizn-novye-tehnologii-sberezheniya-zdorovya/struktura-i-klyuchevye-meropriyatiya-federalnogo-proekta-optimalnaya-dlya-vosstanovleniya-zdorovya-meditsinskaya-reabilitatsiya',
|
||||
},
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Экономика действующего кабинета */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const leakStages = [
|
||||
{ label: ['Не предложили', 'курс'], percent: '−35%' },
|
||||
{ label: ['Нет повторных', 'визитов'], percent: '−30%' },
|
||||
{ label: ['Нет регулярного', 'сопровождения'], percent: '−25%' },
|
||||
{ label: ['Нет комплексного', 'подхода'], percent: '−20%' },
|
||||
]
|
||||
|
||||
export const leakBreakdown = [
|
||||
{ label: 'Не предложили курс (−35%)', value: '≈ 9 450 000 ₽' },
|
||||
{ label: 'Нет повторных визитов (−30%)', value: '≈ 6 750 000 ₽' },
|
||||
{ label: 'Нет регулярного сопровождения (−25%)', value: '≈ 2 970 000 ₽' },
|
||||
{ label: 'Нет комплексного подхода (−20%)', value: '≈ 810 000 ₽' },
|
||||
]
|
||||
|
||||
export const leakSolutions = [
|
||||
{ icon: 'gear', title: 'Системная работа', text: 'Стандартизация процессов, назначений и скриптов.' },
|
||||
{ icon: 'clipboard', title: 'Курсовой подход', text: 'План лечения оформляется как понятный курс.' },
|
||||
{ icon: 'repeat', title: 'Повторные визиты', text: 'Контроль результата и плановое сопровождение пациента.' },
|
||||
{ icon: 'layers', title: 'Комплексные программы', text: 'Комбинация действующих методик и оборудования ЭКЗО.' },
|
||||
] as const
|
||||
|
||||
export const revenueFormula = [
|
||||
{ value: '1 000', label: 'активных пациентов в базе за год' },
|
||||
{ value: '27 000 ₽', label: 'средний чек курса' },
|
||||
{ value: '4', label: 'цикла в год' },
|
||||
]
|
||||
|
||||
export const revenueResult = {
|
||||
value: '108 000 000 ₽',
|
||||
label: 'дополнительной выручки в год без вложений в маркетинг',
|
||||
}
|
||||
|
||||
export const revenueMetrics = [
|
||||
{ value: '+108 000 000 ₽', label: 'дополнительная выручка в год' },
|
||||
{ value: '4 цикла', label: 'повторного маршрута пациента в год в сценарной модели' },
|
||||
{ value: '0 ₽', label: 'дополнительных расходов на маркетинг' },
|
||||
{ value: 'от 6 мес.', label: 'срок окупаемости' },
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Сценарии выручки */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
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 formula band no longer carries a bare «без учёта расходов» aside: the
|
||||
* average cheque now has to say where it comes from, so the aside became a
|
||||
* three-paragraph column beside the tag.
|
||||
*/
|
||||
export const revenueFormulaCopy = {
|
||||
tag: 'Формула расчёта',
|
||||
headline: 'Оплаченные курсы × средний чек ≈ 27 000 ₽',
|
||||
sourcePrefix: 'Средний чек курса составляет ',
|
||||
sourceAmount: '27 000 ₽',
|
||||
sourceSuffix: ', по данным нашей клиники «ЭкзоКлиник» в городе Тольятти за период 2025г.–2026г.',
|
||||
caveat: 'Средний чек сеанса может меняться в зависимости от региона и сезонных предложений.',
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Калькулятор сценария */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const calculatorCopy = {
|
||||
eyebrow: 'Калькулятор сценария',
|
||||
title: 'Посчитайте выручку и возврат инвестиций',
|
||||
lead: 'Укажите загрузку, средний чек и объём вложений — калькулятор сразу покажет выручку, срок возврата и ROI.',
|
||||
caption:
|
||||
'выручка показывает сценарный потенциал продаж. Срок возврата и ROI рассчитаны до учёта ФОТ, аренды, расходников и налогов. Финальная экономика уточняется после аудита кабинета.',
|
||||
}
|
||||
|
||||
/** Prefilled with the «рабочая загрузка» scenario from the cards above. */
|
||||
export const calculatorFields = [
|
||||
{
|
||||
key: 'courses',
|
||||
label: 'Оплаченных курсов в месяц',
|
||||
hint: 'Сколько курсов кабинет продаёт за месяц.',
|
||||
ariaLabel: 'Количество оплаченных курсов в месяц',
|
||||
initial: 60,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: 'averageCheck',
|
||||
label: 'Средний чек курса, ₽',
|
||||
hint: 'Средняя стоимость одного оплаченного курса.',
|
||||
ariaLabel: 'Средний чек курса в рублях',
|
||||
initial: 27_000,
|
||||
step: 500,
|
||||
},
|
||||
{
|
||||
key: 'investment',
|
||||
label: 'Инвестиции в модернизацию, ₽',
|
||||
hint: 'Вложения для расчёта срока возврата и ROI.',
|
||||
ariaLabel: 'Инвестиции в модернизацию кабинета в рублях',
|
||||
initial: 5_000_000,
|
||||
step: 100_000,
|
||||
},
|
||||
] as const
|
||||
|
||||
export const calculatorResults = [
|
||||
{ key: 'month', label: 'Дополнительная выручка / месяц', hint: 'до вычета расходов', accent: true },
|
||||
{ key: 'year', label: 'Дополнительная выручка / год', hint: 'при выбранной загрузке', accent: false },
|
||||
{ key: 'payback', label: 'Срок возврата инвестиций', hint: 'инвестиции ÷ выручка в месяц', accent: false },
|
||||
{ key: 'roi', label: 'ROI за 12 месяцев', hint: 'сценарно, до операционных расходов', accent: false },
|
||||
] as const
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Модернизация */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const promises = [
|
||||
{
|
||||
icon: 'load',
|
||||
title: 'Загрузить действующее оборудование',
|
||||
text: 'Вернуть полезным аппаратам точную роль, назначающих врачей, расписание и понятный курс.',
|
||||
},
|
||||
{
|
||||
icon: 'gap',
|
||||
title: 'Закрыть клинические пробелы',
|
||||
text: 'Добавить только те EXO-модули, которых не хватает текущим врачебным маршрутам.',
|
||||
},
|
||||
{
|
||||
icon: 'throughput',
|
||||
title: 'Увеличить пропускную способность',
|
||||
text: 'Развести активные и параллельные процедуры, сократить простои и собрать пакетные программы.',
|
||||
},
|
||||
{
|
||||
icon: 'result',
|
||||
title: 'Управлять результатом',
|
||||
text: 'Измерять назначения, завершение курса, загрузку, динамику, повторные программы и экономику.',
|
||||
},
|
||||
] as const
|
||||
|
||||
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 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: 'Действующие методы + EXO по этапу реабилитации.' },
|
||||
{ title: 'Контроль реакции', text: 'Самочувствие, движение, переносимость и динамика жалоб.' },
|
||||
{ title: 'Повтор / следующий этап', text: 'Поддерживающая программа или корректировка маршрута.' },
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Модели усиления и пилот */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const formats = [
|
||||
{
|
||||
area: 'от 12 м²',
|
||||
title: 'Один кабинет',
|
||||
text: 'Стартовый формат под отдельный сценарий и ограниченную конфигурацию.',
|
||||
},
|
||||
{
|
||||
area: 'от 35 м²',
|
||||
title: 'Два кабинета',
|
||||
text: 'Базовая инфраструктура с разделением активных и параллельных процедур.',
|
||||
},
|
||||
{
|
||||
area: 'от 45 м²',
|
||||
title: 'Три кабинета',
|
||||
text: 'Расширенный формат для нескольких маршрутов и большей пропускной способности.',
|
||||
},
|
||||
]
|
||||
|
||||
export const timeline = [
|
||||
{
|
||||
week: '1–2 неделя.',
|
||||
title: 'Сбор данных и комплексный аудит кабинета',
|
||||
lead: 'Мы проводим глубокий технический аудит текущего оборудования. Анализируем действующее направление по нозологиям, сроки лечения и методики.',
|
||||
items: [
|
||||
{
|
||||
title: 'Инвентаризация и аудит оборудования',
|
||||
text: 'Проверка технического состояния, износа и реальной загрузки каждого действующего аппарата клиники.',
|
||||
},
|
||||
{
|
||||
title: 'Анализ расписания и логистики',
|
||||
text: 'Поиск «узких мест» в сетке записи, из-за которых простаивают кабинеты и формируются простои у врачей.',
|
||||
},
|
||||
{
|
||||
title: 'Экспертиза документов',
|
||||
text: 'Проверка действующих медицинских лицензий, соответствия стандартам оснащения Минздрава и текущих карт назначений.',
|
||||
},
|
||||
{
|
||||
title: 'Анализ штата и мотивации',
|
||||
text: 'Оценка квалификации врачей и медсестёр, анализ их загрузки и текущей схемы оплаты труда.',
|
||||
},
|
||||
{
|
||||
title: 'Оцифровка экономики и потока',
|
||||
text: 'Расчёт стоимости одного визита пациента, маржинальности отдельных процедур и объёма упущенной прибыли клиники.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '3–4 неделя.',
|
||||
title: 'Разработка карты пробелов и проектирование EXO-маршрутов',
|
||||
lead: 'На основе аудита мы создаём индивидуальное инженерное и медицинское решение под специфику вашей клиники.',
|
||||
items: [
|
||||
{ title: 'План по увеличению нагрузки на действующие аппараты' },
|
||||
{ title: 'План по увеличению пропускной способности одного кабинета' },
|
||||
{ title: 'Формирование индивидуальных комплексных методик' },
|
||||
{ title: 'План перезонирования' },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '5–6 неделя.',
|
||||
title: 'Бесшовное дооснащение и комплексное обучение команды',
|
||||
lead: 'Мы интегрируем оборудование и готовим команду к новой модели работы без остановки текущего приёма пациентов. Все технические и образовательные процессы подстраиваются под график вашей клиники.',
|
||||
items: [
|
||||
{
|
||||
title: 'Бесшовный монтаж и калибровка',
|
||||
text: 'Установка модулей производится в удобное для вас время, включая выходные или вечерние часы. Клиника продолжает принимать пациентов в штатном режиме — вы не теряете ни одного дня выручки.',
|
||||
},
|
||||
{
|
||||
title: 'Гибкий формат обучения персонала',
|
||||
text: 'Мы подбираем комфортный график занятий без отрыва сотрудников от работы. Дополнительно доступна опция обучения на базе нашей действующей клиники.',
|
||||
},
|
||||
{
|
||||
title: 'Внедрение технологических карт',
|
||||
text: 'Интеграция готовых протоколов лечения в МИС клиники для быстрой работы врачей.',
|
||||
},
|
||||
{
|
||||
title: 'Обучение врачей',
|
||||
text: 'Глубокий курс по сочетанным методикам EXO-терапии, биомеханике, оценке динамики пациента и показаниям для курсового лечения.',
|
||||
},
|
||||
{
|
||||
title: 'Инструктаж медицинских сестёр',
|
||||
text: 'Практическая отработка навыков проведения параллельных процедур и контроля безопасности.',
|
||||
},
|
||||
{
|
||||
title: 'Подготовка администраторов',
|
||||
text: 'Обучение сервисного персонала работе со скриптами, алгоритмам удержания пациентов и правилам презентации комплексных пакетов.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
week: '7–8 неделя.',
|
||||
title: 'Мягкий перезапуск, запуск пакетов и аналитика',
|
||||
lead: 'Запуск обновлённого кабинета в работу, перевод текущих пациентов на новые маршруты и фиксация первых финансовых результатов.',
|
||||
items: [
|
||||
{
|
||||
title: 'Работа с действующими пациентами',
|
||||
text: 'Мягкий перевод пациентов с разовых процедур на новые комбинированные EXO-программы.',
|
||||
},
|
||||
{
|
||||
title: 'Старт продаж новых пакетов услуг',
|
||||
text: 'Внедрение высокомаржинальных курсовых продуктов вместо неэффективной поштучной продажи визитов.',
|
||||
},
|
||||
{
|
||||
title: 'Внедрение контроля «до / после»',
|
||||
text: 'Запуск системы объективной оценки результатов лечения: снимки, тесты и шкалы боли.',
|
||||
},
|
||||
{
|
||||
title: 'Аналитика первой прибыли',
|
||||
text: 'Оценка динамики загрузки кабинета, роста среднего чека и удовлетворённости пациентов за первые 14 дней работы.',
|
||||
},
|
||||
{
|
||||
title: 'Дорожная карта по увеличению целевой аудитории',
|
||||
text: 'План расширения спектра оказываемых услуг и дальнейшего развития кабинета.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Почему Экзо Групп */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const whyItems = [
|
||||
{
|
||||
icon: 'audit',
|
||||
title: 'Аудит вместо типовой закупки',
|
||||
text: 'Оцениваем парк, документы, загрузку, назначения и клинические пробелы до формирования комплектации.',
|
||||
},
|
||||
{
|
||||
icon: 'clinic',
|
||||
title: 'Собственная клиническая практика',
|
||||
text: 'Оборудование, маршруты и организация работы проверяются внутри сети Экзо Клиник.',
|
||||
},
|
||||
{
|
||||
icon: 'roles',
|
||||
title: 'Обучение по ролям',
|
||||
text: 'Врачи назначают, медсёстры выполняют, главный врач контролирует, администратор доводит пациента до курса.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const companyStats = [
|
||||
{ value: '100+', label: 'точек установки' },
|
||||
{ value: '60+', label: 'медицинских учреждений' },
|
||||
{ value: '60 дней', label: 'до модели «до / после»' },
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Заявка */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const ctaChecks = [
|
||||
'аудит действующего оборудования и решение по каждому аппарату;',
|
||||
'EXO-конфигурация только под выявленные пробелы;',
|
||||
'план пилота на 60 дней и KPI «до / после».',
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Резидентские статусы */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
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,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,396 @@
|
||||
@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-navy-4: #0e3b5b;
|
||||
--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;
|
||||
/* Without this the snapport starts at the scrollport edge, so `snap-align:
|
||||
start` on the first card resolves to scrollLeft 16 — mandatory snapping
|
||||
then forces that scroll on load and the first card sits on the bezel with
|
||||
no gutter. Matching the inline padding puts the resting state at 0. */
|
||||
scroll-padding-inline: 16px;
|
||||
}
|
||||
|
||||
/* Card widths in these rows are written as `calc(100vw - 62px)` rather than
|
||||
as a percentage of the track (16px gutter + 12px gap + a 34px sliver of the
|
||||
next card). A percentage peek scales with the screen, and past ~40px it
|
||||
starts exposing legible half-words, which reads as broken text rather than
|
||||
as "there is more to the right". A fixed sliver looks the same on every
|
||||
phone. The value stays in the markup as `auto-cols-*` so it keeps its sort
|
||||
position against `sm:grid-still`, which has to win at the breakpoint. */
|
||||
|
||||
.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, anchored to its right edge so that the logo printed on the
|
||||
equipment stays clear of the copy at every width. */
|
||||
.hero-photo {
|
||||
background-position: 100% center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero-photo {
|
||||
background-position: 96% center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-photo {
|
||||
background-position: 94% center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft teal bloom used behind cards and panels, in four sizes/positions. */
|
||||
.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);
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.loss-section-surface {
|
||||
background: linear-gradient(180deg, #f8fcfd 0%, #eef8f8 100%);
|
||||
}
|
||||
|
||||
.loss-panel-surface {
|
||||
background: linear-gradient(145deg, #08253f 0%, #061b2f 100%);
|
||||
}
|
||||
|
||||
.loss-anchor-surface {
|
||||
background: linear-gradient(180deg, rgb(7 58 83 / 0.98), rgb(5 35 58 / 0.98));
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
.loss-solution-surface {
|
||||
background: linear-gradient(180deg, #ffffff, #f5fbfb);
|
||||
}
|
||||
|
||||
.loss-result-surface {
|
||||
background: linear-gradient(135deg, #00b9aa, #00cfc0);
|
||||
}
|
||||
|
||||
/* The horizontal pipe the four leak stages sit on. Its metallic sheen is a
|
||||
five-stop gradient, so it stays a class rather than an arbitrary value. */
|
||||
.loss-pipe {
|
||||
background: linear-gradient(180deg, #eff8fa 0%, #9fbbc4 45%, #dcecef 55%, #547986 100%);
|
||||
box-shadow:
|
||||
0 0 15px rgb(69 228 215 / 0.25),
|
||||
inset 0 1px 1px rgb(255 255 255 / 0.8);
|
||||
}
|
||||
|
||||
/* A drain hole with a droplet falling out of it. */
|
||||
.loss-hole {
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
#061b2f 0 34%,
|
||||
#5f7e88 36% 48%,
|
||||
#d9e9eb 50% 65%,
|
||||
#6c8c96 67% 100%
|
||||
);
|
||||
box-shadow: 0 0 12px rgb(69 228 215 / 0.32);
|
||||
}
|
||||
|
||||
.loss-hole::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 36px;
|
||||
width: 9px;
|
||||
height: 14px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
border-radius: 65% 35% 65% 35%;
|
||||
background: linear-gradient(135deg, #e9fbff, #6da7b7);
|
||||
box-shadow: 0 5px 12px rgb(0 196 180 / 0.22);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.revenue-cta-surface {
|
||||
background: linear-gradient(135deg, rgb(0 196 180 / 0.1), rgb(255 255 255 / 0.035));
|
||||
}
|
||||
|
||||
.residency-surface {
|
||||
background: linear-gradient(180deg, #071d31 0%, #041524 100%);
|
||||
}
|
||||
|
||||
/* Market trend bars: they grow once the surrounding reveal turns visible. */
|
||||
.trend-bar {
|
||||
background: linear-gradient(180deg, var(--color-teal-bright), var(--color-teal));
|
||||
box-shadow: 0 10px 24px rgb(0 196 180 / 0.2);
|
||||
transform: scaleY(0);
|
||||
transform-origin: bottom;
|
||||
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.is-visible .trend-bar {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
@@ -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-existing-physio'
|
||||
const DRAFT_KEY = 'exo_medcenter_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_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>,
|
||||
)
|
||||