Files
exodevices/medcenterstart
Yuriy PanovandClaude Opus 5 160f615390 medcenterstart: apply the v2 design and add the callback dialog
v2 differs from legacy/new only by the callback dialog; the calculator and
the new phone number were already in the React landing.

The header «Звонок» button opens the «Перезвоним вам» dialog from the
fitness landing (name, phone, consent). Unlike fitness, 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 in the section sheet still reports a call click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:36:38 +06:00
..

Физиотерапия с нуля — лендинг для медицинских центров

React-приложение лендинга MedCbezFiz (медицинский центр, где физиотерапии ещё нет). Каждая заявка создаёт сделку в amoCRM (воронка 10980758) и контакт, если его ещё нет.

Стек: Vite + React 19 + TypeScript + Tailwind CSS v4, API — Express 5.


Быстрый старт

npm install
cp .env.example .env   # заполнить AMO_SUBDOMAIN и AMO_LONG_LIVED_TOKEN
npm run dev

npm run dev поднимает Vite на http://localhost:5175 и API на :3002; запросы /api/* проксируются с фронтенда на API.

Порты выбраны так, чтобы все лендинги можно было запускать одновременно: fitnes и medcenter — 5173/3000, hotel — 5174/3001, этот — 5175/3002.

Продакшен

npm run build   # сборка клиента в dist/client
npm start       # Express отдаёт dist/client и обрабатывает /api/*

Один процесс Node на любом VPS. Порт — PORT (по умолчанию 3002).


amoCRM

1. Получить долгосрочный токен

  1. amoCRM → Настройки → Интеграции → Создать интеграцию → Внешняя интеграция.
  2. Название любое (например «Сайт medcenter без физио»), redirect URI — любой рабочий адрес сайта: для долгосрочного токена он не используется.
  3. Права доступа: достаточно Сделки, Контакты (чтение и запись).
  4. Сохранить, открыть интеграцию → вкладка «Ключи и скопы»«Генерировать токен» (долгосрочный, действует ~1 год).
  5. Скопировать токен в .env в AMO_LONG_LIVED_TOKEN, поддомен — в AMO_SUBDOMAIN (для https://exotherapy.amocrm.ru это exotherapy).

Токен читается только на сервере и никогда не попадает в браузерный бандл. Пометьте в календаре дату истечения — токен нужно перевыпустить через год.

2. Проверить подключение

npm run amo:check

Скрипт выведет название аккаунта, первый этап воронки 10980758, в который попадут сделки, и то, в какие поля вашего аккаунта легли данные формы.

3. Что происходит при отправке формы

  1. Поиск контакта по телефону (во всех написаниях: +7…, 8…, только цифры) и по email. Если контакт найден — используется он, недостающий телефон или email дописывается в карточку.
  2. Если контакта нет — создаётся новый с именем, телефоном и email.
  3. Создаётся сделка в воронке AMO_PIPELINE_ID, в её первом этапе, с тегами AMO_LEAD_TAGS + тег формы (заявка с сайта / обратный звонок).
  4. Данные, для которых в аккаунте есть подходящее поле (компания, направления центра, utm_* и т.д.), пишутся в поля; всё остальное — в примечание к сделке. Ничего настраивать в amoCRM заранее не нужно.

Эндпоинт — POST /api/leads/medical-centers-no-physio (адрес взят из исходного лендинга).

Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны имя и телефон; сделка называется Физиотерапия — <центр или имя>. Вторая — модальное окно «Перезвоним вам», его открывает кнопка «Звонок» в шапке (то же окно, что на фитнес-лендинге): имя, телефон и согласие на обработку данных. Сделка называется Обратный звонок — <имя>, в поле или примечании «Форма» — «Быстрый обратный звонок».

Если amoCRM недоступна, заявка не теряется: она пишется в лог сервера ([lead] payload was: …) и сохраняется в localStorage браузера, а посетитель видит телефон для связи.

4. Что происходит при клике на телефон

Ссылка с номером (в окне «Перезвоним вам» и в мобильном меню разделов) помимо набора номера отправляет POST /api/leads/medical-centers-no-physio/call — маячком navigator.sendBeacon, чтобы запрос пережил переход браузера на tel:.

Сервер создаёт сделку без контакта: посетитель не оставил ни имени, ни номера, известен только факт клика. Имя сделки — «Звонок с сайта — Физиотерапия (запуск с нуля)», тег — клик по телефону (плюс AMO_LEAD_TAGS), страница и utm_* идут в поля или в примечание, как и у обычной заявки.

Защита от дублей двойная: на клиенте — один лид на сессию браузера (sessionStorage), на сервере — не больше 4 обращений с одного IP за 30 минут.

Переменные окружения

Переменная Обязательна Описание
AMO_SUBDOMAIN да Поддомен аккаунта, например exotherapy
AMO_LONG_LIVED_TOKEN да Долгосрочный токен интеграции
AMO_PIPELINE_ID нет Воронка для сделок (по умолчанию 10980758)
AMO_RESPONSIBLE_USER_ID нет Ответственный за сделку пользователь
AMO_LEAD_TAGS нет Теги через запятую для всех сделок
PORT нет Порт API/продакшен-сервера (3002)
VITE_SITE_URL нет Канонический адрес, подставляется в canonical и og:
VITE_BASE_PATH нет Подпуть размещения, например /medical-centers/no-physiotherapy/ (по умолчанию /)

Структура

index.html            точка входа Vite (meta, Open Graph, JSON-LD)
src/
  components/         секции лендинга и UI-примитивы
  data/content.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/README.md      исходная инструкция к однофайловой версии
legacy/new/           макет с калькулятором
legacy/v2/            макет v2 — текущий визуальный эталон (legacy/new + окно
                      обратного звонка)

Дизайн-система

Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в @theme внутри src/index.css. Палитра Tailwind по умолчанию отключена (--color-*: initial), поэтому в разметке доступны только цвета EXO. Брейкпоинты соответствуют исходной вёрстке: sm 600, md 760, lg 980, плюс max-width-варианты stack (1050), narrow (760), compact (560), phone (520), tiny (430). Они объявлены от широкого к узкому — Tailwind выводит варианты в порядке объявления, и более узкий должен идти позже, чтобы выигрывать.

Горизонтальные ряды карточек (рынок, обещания, программы, оборудование, форматы, план запуска) используют класс .scroll-cards: на телефоне это лента со snap-скроллом, а на нужной ширине секция сама возвращает её в обычную сетку через sm:grid-still / md:grid-still.

Два места намеренно выбиваются из общей сетки, повторяя оригинал:

  • блок «Сценарии выручки» (.fin-model) сохраняет отступ 32 px на всех ширинах — в исходном CSS правило .fin-model .container перебивает общее расширение контейнера до 48 px с 760 px;
  • план запуска (.timeline) переходит в две колонки на 900 px, а не на 760 px, как остальные секции.

Шрифты

Inter подключён локально пакетом @fontsource-variable/inter — внешних запросов (Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном 100–900, поэтому нестандартные веса из макета (720, 760, 790, 850) отрисовываются по-настоящему, а не округляются до bold.

Символы × не входят в подсеты Inter у Fontsource, поэтому для них всегда используется системный фолбэк. Это заметно только при очень крупном кегле.

Manrope и Segoe UI остались в стеке --font-sans как фолбэк, но отдельно не загружаются.

Экономика направления

Цифры блока «Сценарии выручки» — статические данные из src/data/content.ts, как в исходном лендинге. Калькулятора на странице нет: расчёт делается на аудите.


Соответствие оригиналу

Текст страницы сверялся с legacy/index.html построчно: приложение рендерилось в статический HTML, из обоих файлов извлекался видимый текст и сравнивался. Совпадение полное — единственное расхождение — скрытая подпись honeypot-поля, которой в оригинале нет.

Что сделано иначе

  • Две фотографии аппаратов (ЭкзоЛазер В и ЭкзоИмпульс) в исходном файле были PNG на 1,5 МБ и 750 КБ — здесь они пережаты в webp, как и остальные снимки оборудования. Исходники побайтово совпадают с медцентровским лендингом, поэтому переиспользованы уже готовые webp-версии. Папка ассетов — 664 КБ вместо 2,8 МБ.

  • Мёртвый CSS не переносился. В оригинале лежат 39 правил .patient-economics / .base-economics-card, на которые в разметке нет ни одного элемента. Там же — обработчик анимации счётчиков, который ищет [data-counter]; таких элементов на странице тоже нет.

  • Honeypot получил визуально скрытую подпись — она нужна скринридерам и скрыта утилитой honeypot, а не display:none, иначе поле перестанет быть фокусируемым и ловушка не сработает.

  • Окно «Перезвоним вам» из макета v2 взято с фитнес-лендинга (светлые поля, согласие отдельным чекбоксом, без надзаголовка «Обратный звонок»), а не вёрстка .exo-callback из макета. Тексты окна и маска телефона — из макета: без 11 цифр номера форма не отправляется, как и основная.

Что воспроизведено как есть

  • Блок .system-bottom в секции «Готовое направление» сохраняет верхнюю линию и отступы, хотя сетки карточек над ней в этом лендинге уже нет — так же выглядит и оригинал.
  • Мелкие подписи остались в исходных цветах: надзаголовки секций #008F84 на светлом фоне дают 3,79:1, источники цифр #7E93A3 на белом — 3,19:1. Это ниже 4,5:1, но соответствует оригиналу и остальным лендингам EXO; менять палитру в одном лендинге не стали.

Известные особенности

  • og:image отдаётся по абсолютному адресу VITE_SITE_URL + /og-image.webp; при смене домена обновите VITE_SITE_URL перед сборкой.
  • Шапка прозрачна над первым экраном и получает размытую подложку после 18 px прокрутки — как в оригинале (в лендинге medcenter это правило было перебито, и подложка там видна всегда).