# Физиотерапия с нуля — лендинг для медицинских центров React-приложение лендинга MedCbezFiz (медицинский центр, где физиотерапии ещё нет). Каждая заявка создаёт сделку в amoCRM (воронка `10980758`) и контакт, если его ещё нет. Стек: **Vite + React 19 + TypeScript + Tailwind CSS v4**, API — **Express 5**. --- ## Быстрый старт ```bash 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. ## Продакшен ```bash 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. Проверить подключение ```bash 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 это правило было перебито, и подложка там видна всегда).