Three layout bugs and a mobile experience that had no wayfinding. The `#proposal` section was clipped, not merely cramped: the submit button carried `whitespace-nowrap` from the shared button base, giving it a min-content width of 358px, which sized the section's implicit `auto` grid track to 406px inside a 343px container. `overflow-hidden` then sliced the copy and every form field off the right edge of a 375px screen. Fixed at the root — `whitespace-nowrap` moves out of `base` and into `headerSize`, where buttons are sized by their label — plus an explicit `grid-cols-[minmax(0,1fr)]` on the section. The market stat cards put their source line in absolute position against a guessed min-height, so the longest label ran underneath it. It is in the flow now, pinned by `mt-auto`. Below 980px the header nav is not rendered, which left seventeen screens of scroll with no way to navigate them. The header gives that width back to the page — logo and call button only — and <ThumbBar /> takes over: scroll progress, a live section indicator that opens a section sheet, and one short CTA, hidden over the hero and over the form. Card rows now say what they are. Rows of unordered peers keep scrolling sideways and gain a measured segment indicator (<Rail />), replacing the two prose "swipe sideways" hints. The launch timeline goes the other way: sideways scrolling hid week 3-5 from someone reading week 1-2, so below 900px it becomes four disclosures on a spine — which also recovers the scroll length the taller hero spent. Below 560px the type scale steps down half a level and the display tracking opens back up, and the hero photo zooms past `cover` onto the wave wall, since fitting by height left only ceiling tiles above the copy. Also: the route card inside the market section split into two 165px columns at 760px, so that split now waits for 980px. Desktop above 980px is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Физиотерапия с нуля — лендинг для медицинских центров
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. Получить долгосрочный токен
- amoCRM → Настройки → Интеграции → Создать интеграцию → Внешняя интеграция.
- Название любое (например «Сайт medcenter без физио»), redirect URI — любой рабочий адрес сайта: для долгосрочного токена он не используется.
- Права доступа: достаточно Сделки, Контакты (чтение и запись).
- Сохранить, открыть интеграцию → вкладка «Ключи и скопы» → «Генерировать токен» (долгосрочный, действует ~1 год).
- Скопировать токен в
.envвAMO_LONG_LIVED_TOKEN, поддомен — вAMO_SUBDOMAIN(дляhttps://exotherapy.amocrm.ruэтоexotherapy).
Токен читается только на сервере и никогда не попадает в браузерный бандл. Пометьте в календаре дату истечения — токен нужно перевыпустить через год.
2. Проверить подключение
npm run amo:check
Скрипт выведет название аккаунта, первый этап воронки 10980758, в который
попадут сделки, и то, в какие поля вашего аккаунта легли данные формы.
3. Что происходит при отправке формы
- Поиск контакта по телефону (во всех написаниях:
+7…,8…, только цифры) и по email. Если контакт найден — используется он, недостающий телефон или email дописывается в карточку. - Если контакта нет — создаётся новый с именем, телефоном и email.
- Создаётся сделка в воронке
AMO_PIPELINE_ID, в её первом этапе, с тегамиAMO_LEAD_TAGS+ тегзаявка с сайта. - Данные, для которых в аккаунте есть подходящее поле (компания, направления
центра,
utm_*и т.д.), пишутся в поля; всё остальное — в примечание к сделке. Ничего настраивать в amoCRM заранее не нужно.
Эндпоинт — POST /api/leads/medical-centers-no-physio (адрес взят из исходного
лендинга).
Если amoCRM недоступна, заявка не теряется: она пишется в лог сервера
([lead] payload was: …) и сохраняется в localStorage браузера, а посетитель
видит телефон для связи.
Переменные окружения
| Переменная | Обязательна | Описание |
|---|---|---|
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 это правило было перебито, и подложка там видна всегда).