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 (
-