From 23986ff5ad19d096ebde2cd7a3183c6fa4b0aa25 Mon Sep 17 00:00:00 2001 From: Yuriy Panov Date: Fri, 11 Sep 2026 23:46:16 +0600 Subject: [PATCH] medcenterphysio: apply the v3 design and add the callback dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- medcenterphysio/README.md | 19 +- medcenterphysio/server/src/lead-mapper.ts | 5 +- medcenterphysio/server/src/lead-service.ts | 2 +- medcenterphysio/shared/lead.ts | 2 +- medcenterphysio/src/App.tsx | 9 +- .../src/components/CallbackModal.tsx | 165 ++++++++++++++++++ medcenterphysio/src/components/FormField.tsx | 83 +++++++-- medcenterphysio/src/components/SiteHeader.tsx | 18 +- medcenterphysio/src/hooks/useBodyLock.ts | 10 ++ medcenterphysio/src/hooks/useEscapeKey.ts | 12 ++ medcenterphysio/src/index.css | 6 + 11 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 medcenterphysio/src/components/CallbackModal.tsx create mode 100644 medcenterphysio/src/hooks/useBodyLock.ts create mode 100644 medcenterphysio/src/hooks/useEscapeKey.ts diff --git a/medcenterphysio/README.md b/medcenterphysio/README.md index b232d73..38e8a93 100644 --- a/medcenterphysio/README.md +++ b/medcenterphysio/README.md @@ -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 + окно + обратного звонка и новый номер) ``` ### Дизайн-система diff --git a/medcenterphysio/server/src/lead-mapper.ts b/medcenterphysio/server/src/lead-mapper.ts index b0b076d..d03abef 100644 --- a/medcenterphysio/server/src/lead-mapper.ts +++ b/medcenterphysio/server/src/lead-mapper.ts @@ -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}` } /** diff --git a/medcenterphysio/server/src/lead-service.ts b/medcenterphysio/server/src/lead-service.ts index 47f9553..70c22a7 100644 --- a/medcenterphysio/server/src/lead-service.ts +++ b/medcenterphysio/server/src/lead-service.ts @@ -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. diff --git a/medcenterphysio/shared/lead.ts b/medcenterphysio/shared/lead.ts index f97588b..feae6ed 100644 --- a/medcenterphysio/shared/lead.ts +++ b/medcenterphysio/shared/lead.ts @@ -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 diff --git a/medcenterphysio/src/App.tsx b/medcenterphysio/src/App.tsx index efcadc9..f607e2f 100644 --- a/medcenterphysio/src/App.tsx +++ b/medcenterphysio/src/App.tsx @@ -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 ( <> - +
@@ -35,6 +41,7 @@ export default function App() { + ) } diff --git a/medcenterphysio/src/components/CallbackModal.tsx b/medcenterphysio/src/components/CallbackModal.tsx new file mode 100644 index 0000000..488aad4 --- /dev/null +++ b/medcenterphysio/src/components/CallbackModal.tsx @@ -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(null) + const firstFieldRef = useRef(null) + const phoneRef = useRef(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({ 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) { + 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 ( +
{ + 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]" + > +
+ + +

+ Перезвоним вам +

+

+ Оставьте имя и телефон — наш специалист свяжется с вами и ответит на вопросы. Или позвоните сами:{' '} + + {contacts.phone} + + . +

+ +
+
+ setName(event.target.value)} + /> + setPhone(formatRuPhone(event.target.value))} + /> +
+ + + + + + +
+ {status.text} +
+ +
+
+ ) +} diff --git a/medcenterphysio/src/components/FormField.tsx b/medcenterphysio/src/components/FormField.tsx index 4ed66c4..b288f1e 100644 --- a/medcenterphysio/src/components/FormField.tsx +++ b/medcenterphysio/src/components/FormField.tsx @@ -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 = { + 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 = { + 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 (
-