Convert medical-centre landing to React + Tailwind v4 with amoCRM lead capture
Ports the single-file MedCsFiz page — the medical centre that already runs physiotherapy and wants the existing room to earn more — to the same architecture as the fitness and hotel landings: Vite + React 19 + TypeScript + Tailwind v4 on the client, Express 5 for the API, zod schema shared between the two. The original stays in legacy/index.html as the visual reference. Its 4 MB of inlined CSS, JS and base64 images become 12 asset files (664 KB) plus a bundle loaded on demand; the two 1.5 MB / 750 KB device PNGs are recompressed to webp. The page is split into 18 components with all copy moved to src/data/content.ts. Leads reuse the fitness amoCRM integration: contact lookup by phone in every Russian spelling, deal in the first stage of pipeline 10980758, account fields matched automatically with the rest written to a note. Rate limit, honeypot and a localStorage fallback so a lead survives the CRM being down. Endpoint is /api/leads/medical-centers-existing-physio; Vite runs on 5173 and the API on 3000. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
35f713a267
commit
8b954ea5db
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user