Files
exodevices/medcenterstart/README.md
T
Yuriy PanovandClaude Opus 5 e13854e918 leads: new phone number and an amoCRM lead on every call-button click
The published number becomes +7 927 789-60-71 across all four landings —
`contacts` in each `src/data/content.ts`, plus the fallback messages in the
lead endpoints and `src/lib/lead.ts` that spell it out when amoCRM is down.
The `legacy/` reference pages keep the old number: they are the visual
originals, not something that ships.

Tapping the phone button now also creates a lead. It cannot reuse the form
path: a click carries no name and no number, so `callClickSchema` in
`shared/lead.ts` validates the tracking data alone, `LeadService.submitCallClick`
creates a contactless lead tagged `клик по телефону`, and `AmoClient.createLead`
takes an optional `contactId` for it. The mapper's field/note split moved into
`collect()` so both lead kinds share it.

The browser fires this with `sendBeacon` (falling back to `fetch keepalive`),
because the same click hands the page to `tel:` and a plain fetch would be
cut off mid-flight. Duplicates are held down from both ends: one lead per
browser session on the client, four per IP per 30 minutes on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:58:15 +06:00

14 KiB
Raw Blame History

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

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/              появление секций при скролле, состояние шапки
  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      исходная инструкция к однофайловой версии

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

Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в @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, иначе поле перестанет быть фокусируемым и ловушка не сработает.

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

  • Блок .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 это правило было перебито, и подложка там видна всегда).