medcenterphysio: apply the v3 design and add the callback dialog

v3 differs from legacy/new only by the callback dialog; the new phone
number was already in the React landing.

The header «Звонок» button opens the «Перезвоним вам» dialog from the
fitness landing (name, phone, consent), ported the same way as in
medcenterstart: the phone field keeps this landing's +7 mask and refuses
fewer than 11 digits, as the mockup does. The server accepts form
'callback', names the deal «Обратный звонок — <имя>» and tags it
«обратный звонок». The phone link inside the dialog and the call button
in the mobile action bar still report a call click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-11 23:46:16 +06:00
co-authored by Claude Opus 5
parent cbf253c573
commit 23986ff5ad
11 changed files with 299 additions and 32 deletions
+15 -4
View File
@@ -62,18 +62,25 @@ npm run amo:check
email дописывается в карточку.
2. Если контакта нет — создаётся новый с именем, телефоном и email.
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`.
с тегами `AMO_LEAD_TAGS` + тег формы (`заявка с сайта` / `обратный звонок`).
4. Данные, для которых в аккаунте есть подходящее поле (компания, состояние
кабинета, профиль, `utm_*` и т.д.), пишутся в поля; всё остальное — в
примечание к сделке. Ничего настраивать в amoCRM заранее не нужно.
Форм две. Основная — блок заявки внизу страницы: обязательны имя и телефон;
сделка называется `Физиотерапия — <центр или имя>`. Вторая — модальное окно
«Перезвоним вам», его открывает кнопка «Звонок» в шапке (то же окно, что на
фитнес-лендинге): имя, телефон и согласие на обработку данных. Без 11 цифр
номера форма не отправляется. Сделка называется `Обратный звонок — <имя>`, в
поле или примечании «Форма» — «Быстрый обратный звонок».
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи.
### 4. Что происходит при клике на телефон
Кнопка звонка (в шапке и в нижней мобильной панели) помимо набора номера отправляет
Ссылка с номером (в окне «Перезвоним вам» и кнопка звонка в нижней мобильной панели) помимо набора номера отправляет
`POST /api/leads/medical-centers-existing-physio/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
@@ -107,14 +114,18 @@ index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
hooks/useReveal.ts анимация появления секций при скролле
hooks/ появление секций при скролле, блокировка прокрутки и
Escape для модального окна
lib/ отправка заявки, маска телефона
assets/images/ 12 изображений, извлечённых из исходного HTML
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM
legacy/index.html исходный однофайловый лендинг (визуальный эталон)
legacy/index.html исходный однофайловый лендинг (первая версия дизайна)
legacy/new/ макет с калькулятором
legacy/v3/ макет v3 — текущий визуальный эталон (legacy/new + окно
обратного звонка и новый номер)
```
### Дизайн-система
+3 -2
View File
@@ -133,7 +133,7 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на усиление кабинета физиотерапии',
form: payload.form === 'callback' ? 'Быстрый обратный звонок' : 'Заявка на усиление кабинета физиотерапии',
...payload.utm,
}
@@ -152,7 +152,8 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
/** Lead title shown in the pipeline. */
export function buildLeadName(payload: LeadPayload): string {
return `Физиотерапия — ${payload.company ?? payload.name}`
const who = payload.company ?? payload.name
return payload.form === 'callback' ? `Обратный звонок — ${who}` : `Физиотерапия — ${who}`
}
/**
+1 -1
View File
@@ -115,7 +115,7 @@ export class LeadService {
name: buildLeadName(payload),
contactId,
fields,
tags: ['заявка с сайта'],
tags: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'],
})
// 3. Everything the account has no field for lands in a readable note.
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod'
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
export const leadFormIds = ['request'] as const
export const leadFormIds = ['request', 'callback'] as const
export type LeadFormId = (typeof leadFormIds)[number]
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
+8 -1
View File
@@ -1,3 +1,5 @@
import { useCallback, useState } from 'react'
import { CallbackModal } from './components/CallbackModal'
import { EquipmentSection } from './components/EquipmentSection'
import { Hero } from './components/Hero'
import { LaunchSection } from './components/LaunchSection'
@@ -14,9 +16,13 @@ import { SiteHeader } from './components/SiteHeader'
import { WhyExoSection } from './components/WhyExoSection'
export default function App() {
const [callbackOpen, setCallbackOpen] = useState(false)
const openCallback = useCallback(() => setCallbackOpen(true), [])
const closeCallback = useCallback(() => setCallbackOpen(false), [])
return (
<>
<SiteHeader />
<SiteHeader onCallbackClick={openCallback} />
<main>
<Hero />
@@ -35,6 +41,7 @@ export default function App() {
<SiteFooter />
<MobileActionBar />
<CallbackModal open={callbackOpen} onClose={closeCallback} />
</>
)
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { contacts } from '../data/content'
import { useBodyLock } from '../hooks/useBodyLock'
import { useEscapeKey } from '../hooks/useEscapeKey'
import { cx } from '../lib/cx'
import { reportCallClick, submitLead } from '../lib/lead'
import { formatRuPhone, isCompleteRuPhone } from '../lib/phone'
import { Button } from './Button'
import { ConsentCheckbox, Honeypot, TextField } from './FormField'
import { ArrowRightIcon } from './Icons'
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
/**
* Compact second entry point: name + phone only. Leads land in the same
* amoCRM pipeline, tagged so they can be told apart from the full request.
* Same dialog as the fitness landing's; the phone mask is this landing's own.
*/
export function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const formRef = useRef<HTMLFormElement>(null)
const firstFieldRef = useRef<HTMLInputElement>(null)
const phoneRef = useRef<HTMLInputElement>(null)
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [consent, setConsent] = useState(false)
const [honeypot, setHoneypot] = useState('')
const [pending, setPending] = useState(false)
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
const close = useCallback(() => onClose(), [onClose])
useBodyLock(open)
useEscapeKey(open, close)
useEffect(() => {
if (open) firstFieldRef.current?.focus()
}, [open])
if (!open) return null
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = formRef.current
if (!form) return
if (!form.checkValidity()) {
form.reportValidity()
setStatus({ text: 'Проверьте обязательные поля и согласие.', tone: 'error' })
return
}
if (!isCompleteRuPhone(phone)) {
setStatus({ text: 'Укажите полный номер телефона.', tone: 'error' })
phoneRef.current?.focus()
return
}
if (honeypot) return
setPending(true)
setStatus({ text: '', tone: 'idle' })
const result = await submitLead('callback', { name, phone, website: honeypot })
if (result.ok) {
setName('')
setPhone('')
setConsent(false)
setStatus({ text: 'Спасибо. Мы перезвоним в ближайшее рабочее время.', tone: 'success' })
} else {
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
}
setPending(false)
}
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="callbackTitle"
onClick={(event) => {
if (event.target === event.currentTarget) close()
}}
className="fixed inset-0 z-100 flex items-center justify-center bg-[#020D16]/[0.92] p-[18px] backdrop-blur-[10px]"
>
<div className="relative max-h-[calc(100svh_-_36px)] w-full max-w-[420px] overflow-y-auto rounded-[28px] bg-white p-[clamp(24px,4vw,34px)] text-ink shadow-deep">
<button
type="button"
aria-label="Закрыть"
onClick={close}
className="absolute top-[14px] right-[14px] size-[38px] cursor-pointer rounded-full border border-navy/15 text-[22px] text-muted hover:border-teal hover:text-navy"
>
×
</button>
<h3 id="callbackTitle" className="mb-[8px] text-[26px]">
Перезвоним вам
</h3>
<p className="mb-[20px] text-[13px] text-muted">
Оставьте имя и телефон наш специалист свяжется с вами и ответит на вопросы. Или позвоните сами:{' '}
<a
className="font-extrabold text-navy underline underline-offset-[3px]"
href={contacts.phoneHref}
onClick={reportCallClick}
>
{contacts.phone}
</a>
.
</p>
<form ref={formRef} noValidate onSubmit={onSubmit}>
<div className="grid gap-[12px]">
<TextField
ref={firstFieldRef}
tone="light"
id="callbackName"
label="Имя *"
name="name"
autoComplete="name"
placeholder="Ваше имя"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
<TextField
ref={phoneRef}
tone="light"
id="callbackPhone"
label="Телефон *"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
placeholder="+7 999 000-00-00"
required
value={phone}
onChange={(event) => setPhone(formatRuPhone(event.target.value))}
/>
</div>
<Honeypot id="callbackWebsite" value={honeypot} onChange={setHoneypot} />
<ConsentCheckbox checked={consent} onChange={setConsent} />
<Button type="submit" className="w-full" disabled={pending} aria-busy={pending}>
<span>{pending ? 'Отправляем…' : 'Жду звонка'}</span>
{pending ? null : <ArrowRightIcon />}
</Button>
<div
role="status"
aria-live="polite"
className={cx(
'mt-[11px] min-h-[20px] text-[12px]',
status.tone === 'success' && 'text-[#087F73]',
status.tone === 'error' && 'text-[#A14D12]',
status.tone === 'idle' && 'text-muted',
)}
>
{status.text}
</div>
</form>
</div>
</div>
)
}
+68 -15
View File
@@ -1,16 +1,44 @@
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)]'
/**
* `glass` — controls on the navy CTA panel; `light` — on the white callback
* dialog, the same fields as the fitness landing's.
*/
type Tone = 'glass' | 'light'
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
const controlClass: Record<Tone, string> = {
glass:
'w-full rounded-[14px] border border-white/[0.18] bg-white/[0.08] px-[15px] 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)]',
light:
'w-full rounded-[15px] border border-navy/15 bg-field px-[15px] py-[14px] text-ink outline-none ' +
'transition-[border-color,box-shadow,background-color] duration-200 ' +
'focus:border-teal focus:bg-white focus:shadow-[0_0_0_4px_rgb(0_196_180/0.10)]',
}
const labelClass: Record<Tone, string> = {
glass: 'text-[12px] font-[750] text-white/[0.7]',
light: 'text-[11px] font-extrabold text-muted',
}
function Field({
id,
label,
full,
tone,
children,
}: {
id: string
label: string
full?: boolean
tone: Tone
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 htmlFor={id} className={labelClass[tone]}>
{label}
</label>
{children}
@@ -22,11 +50,12 @@ export function TextField({
id,
label,
full,
tone = 'glass',
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
}: { id: string; label: string; full?: boolean; tone?: Tone } & ComponentPropsWithRef<'input'>) {
return (
<Field id={id} label={label} full={full}>
<input id={id} className={cx(controlClass, 'min-h-[52px] px-[15px]')} {...rest} />
<Field id={id} label={label} full={full} tone={tone}>
<input id={id} className={cx(controlClass[tone], tone === 'glass' ? 'min-h-[52px]' : 'h-[51px]')} {...rest} />
</Field>
)
}
@@ -38,20 +67,29 @@ export function TextareaField({
...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 id={id} label={label} full={full} tone="glass">
<textarea id={id} className={cx(controlClass.glass, 'min-h-[104px] resize-y 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 }) {
export function Honeypot({
id = 'website',
value,
onChange,
}: {
/** Must differ per form: the request form and the callback dialog can be on screen together. */
id?: string
value: string
onChange: (value: string) => void
}) {
return (
<div aria-hidden="true" className="honeypot">
<label htmlFor="website">Не заполнять</label>
<label htmlFor={id}>Не заполнять</label>
<input
autoComplete="off"
id="website"
id={id}
name="website"
tabIndex={-1}
type="text"
@@ -61,3 +99,18 @@ export function Honeypot({ value, onChange }: { value: string; onChange: (value:
</div>
)
}
export function ConsentCheckbox({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
return (
<label className="my-[13px] grid grid-cols-[18px_1fr] gap-[9px] text-[10px] text-muted">
<input
type="checkbox"
required
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-[2px] mb-[3px] accent-teal"
/>
<span>Согласен на обработку персональных данных и получение обратного звонка.</span>
</label>
)
}
+10 -8
View File
@@ -1,6 +1,5 @@
import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content'
import { reportCallClick } from '../lib/lead'
import { navLinks } from '../data/content'
import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons'
import { Container } from './Layout'
@@ -16,8 +15,10 @@ import { Container } from './Layout'
* 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.
*
* «Звонок» opens the callback dialog rather than dialling straight away.
*/
export function SiteHeader() {
export function SiteHeader({ onCallbackClick }: { onCallbackClick: () => void }) {
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]">
@@ -43,10 +44,11 @@ export function SiteHeader() {
</nav>
<div className="flex shrink-0 items-center gap-[8px]">
<a
href={contacts.phoneHref}
onClick={reportCallClick}
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
<button
type="button"
onClick={onCallbackClick}
aria-label="Заказать обратный звонок"
aria-haspopup="dialog"
className={buttonClass(
'call',
undefined,
@@ -56,7 +58,7 @@ export function SiteHeader() {
>
<PhoneIcon />
<span className="hidden md:inline">Звонок</span>
</a>
</button>
{/* `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`. */}
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from 'react'
/** Freezes background scrolling while a modal is open. */
export function useBodyLock(locked: boolean) {
useEffect(() => {
if (!locked) return
document.body.classList.add('is-locked')
return () => document.body.classList.remove('is-locked')
}, [locked])
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect } from 'react'
export function useEscapeKey(active: boolean, onEscape: () => void) {
useEffect(() => {
if (!active) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [active, onEscape])
}
+6
View File
@@ -27,6 +27,8 @@
--color-paper: #f5fafb;
--color-ink: #0a2540;
--color-muted: #60778b;
/* Inputs on the white callback dialog. */
--color-field: #f7fafb;
--color-footer: #041524;
/* The dark "сценарии выручки" band sits between navy and navy-deep. */
--color-scenario: #071f35;
@@ -84,6 +86,10 @@
overflow-x: hidden;
}
body.is-locked {
overflow: hidden;
}
/* The fixed header is 78px tall; anchors must clear it. */
section[id] {
scroll-margin-top: 78px;