diff --git a/medcenterstart/README.md b/medcenterstart/README.md
index 2768222..47c1be5 100644
--- a/medcenterstart/README.md
+++ b/medcenterstart/README.md
@@ -65,7 +65,7 @@ npm run amo:check
email дописывается в карточку.
2. Если контакта нет — создаётся новый с именем, телефоном и email.
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
- с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`.
+ с тегами `AMO_LEAD_TAGS` + тег формы (`заявка с сайта` / `обратный звонок`).
4. Данные, для которых в аккаунте есть подходящее поле (компания, направления
центра, `utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к
сделке. Ничего настраивать в amoCRM заранее не нужно.
@@ -73,13 +73,20 @@ npm run amo:check
Эндпоинт — `POST /api/leads/medical-centers-no-physio` (адрес взят из исходного
лендинга).
+Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны имя и
+телефон; сделка называется `Физиотерапия — <центр или имя>`. Вторая — модальное
+окно «Перезвоним вам», его открывает кнопка «Звонок» в шапке (то же окно, что на
+фитнес-лендинге): имя, телефон и согласие на обработку данных. Сделка называется
+`Обратный звонок — <имя>`, в поле или примечании «Форма» — «Быстрый обратный
+звонок».
+
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи.
### 4. Что происходит при клике на телефон
-Кнопка звонка (в шапке и в мобильном меню разделов) помимо набора номера отправляет
+Ссылка с номером (в окне «Перезвоним вам» и в мобильном меню разделов) помимо набора номера отправляет
`POST /api/leads/medical-centers-no-physio/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
@@ -113,15 +120,19 @@ index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
- hooks/ появление секций при скролле, состояние шапки
+ 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/README.md исходная инструкция к однофайловой версии
+legacy/new/ макет с калькулятором
+legacy/v2/ макет v2 — текущий визуальный эталон (legacy/new + окно
+ обратного звонка)
```
### Дизайн-система
@@ -191,6 +202,11 @@ honeypot-поля, которой в оригинале нет.
скрыта утилитой `honeypot`, а не `display:none`, иначе поле перестанет быть
фокусируемым и ловушка не сработает.
+* **Окно «Перезвоним вам»** из макета v2 взято с фитнес-лендинга (светлые поля,
+ согласие отдельным чекбоксом, без надзаголовка «Обратный звонок»), а не
+ вёрстка `.exo-callback` из макета. Тексты окна и маска телефона — из макета:
+ без 11 цифр номера форма не отправляется, как и основная.
+
### Что воспроизведено как есть
* Блок `.system-bottom` в секции «Готовое направление» сохраняет верхнюю
diff --git a/medcenterstart/server/src/lead-mapper.ts b/medcenterstart/server/src/lead-mapper.ts
index 4629efb..93cce2e 100644
--- a/medcenterstart/server/src/lead-mapper.ts
+++ b/medcenterstart/server/src/lead-mapper.ts
@@ -139,7 +139,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,
}
@@ -158,7 +158,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/medcenterstart/server/src/lead-service.ts b/medcenterstart/server/src/lead-service.ts
index 47f9553..70c22a7 100644
--- a/medcenterstart/server/src/lead-service.ts
+++ b/medcenterstart/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/medcenterstart/shared/lead.ts b/medcenterstart/shared/lead.ts
index 76deda4..15ba8a4 100644
--- a/medcenterstart/shared/lead.ts
+++ b/medcenterstart/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/medcenterstart/src/App.tsx b/medcenterstart/src/App.tsx
index 267937f..2d1705b 100644
--- a/medcenterstart/src/App.tsx
+++ b/medcenterstart/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'
@@ -13,9 +15,13 @@ import { ThumbBar } from './components/ThumbBar'
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 (
<>
-
+
@@ -33,6 +39,7 @@ export default function App() {
+
>
)
}
diff --git a/medcenterstart/src/components/CallbackModal.tsx b/medcenterstart/src/components/CallbackModal.tsx
new file mode 100644
index 0000000..488aad4
--- /dev/null
+++ b/medcenterstart/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 (
+