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>
12 KiB
ЭкзоОтель — лендинг для отелей
React-приложение лендинга «ЭкзоОтель». Каждая заявка создаёт сделку в amoCRM
(воронка 10980758) и контакт, если его ещё нет.
Стек: Vite + React 19 + TypeScript + Tailwind CSS v4, API — Express 5.
Архитектура та же, что у лендинга для фитнес-клубов (../fitnes).
Быстрый старт
npm install
cp .env.example .env # заполнить AMO_SUBDOMAIN и AMO_LONG_LIVED_TOKEN
npm run dev
npm run dev поднимает Vite на http://localhost:5174 и API на :3001;
запросы /api/* проксируются с фронтенда на API. Порты отличаются от
фитнес-лендинга (5173 / 3000), поэтому оба сайта можно держать запущенными
одновременно.
Продакшен
npm run build # сборка клиента в dist/client
npm start # Express отдаёт dist/client и обрабатывает /api/*
Один процесс Node на любом VPS. Порт — PORT (по умолчанию 3001).
amoCRM
1. Получить долгосрочный токен
- amoCRM → Настройки → Интеграции → Создать интеграцию → Внешняя интеграция.
- Название любое (например «Сайт hotels»), redirect URI — любой рабочий адрес сайта: для долгосрочного токена он не используется.
- Права доступа: достаточно Сделки, Контакты (чтение и запись).
- Сохранить, открыть интеграцию → вкладка «Ключи и скопы» → «Генерировать токен» (долгосрочный, действует ~1 год).
- Скопировать токен в
.envвAMO_LONG_LIVED_TOKEN, поддомен — вAMO_SUBDOMAIN(дляhttps://exotherapy.amocrm.ruэтоexotherapy).
Токен читается только на сервере и никогда не попадает в браузерный бандл. Пометьте в календаре дату истечения — токен нужно перевыпустить через год.
Токен можно взять тот же, что у фитнес-лендинга: сделки различаются по тегу
(AMO_LEAD_TAGS=hotel-landing) и по названию сделки ЭкзоОтель — ….
2. Проверить подключение
npm run amo:check
Скрипт выведет название аккаунта, первый этап воронки 10980758, в который
попадут сделки, и то, в какие поля вашего аккаунта легли данные формы.
3. Что происходит при отправке формы
- Поиск контакта по телефону (во всех написаниях:
+7…,8…, только цифры) и по email. Если контакт найден — используется он, недостающий телефон или email дописывается в карточку. - Если контакта нет — создаётся новый с именем, телефоном и email.
- Создаётся сделка в воронке
AMO_PIPELINE_ID, в её первом этапе, с тегамиAMO_LEAD_TAGS+ тегзаявка с сайта. - Данные, для которых в аккаунте есть подходящее поле (отель, комментарий,
utm_*и т.д.), пишутся в поля; всё остальное — в примечание к сделке. Ничего настраивать в amoCRM заранее не нужно.
Если amoCRM недоступна, заявка не теряется: она пишется в лог сервера
([lead] payload was: …) и сохраняется в localStorage браузера, а посетитель
видит телефон для связи.
4. Что происходит при клике на телефон
Кнопка звонка (в шапке) помимо набора номера отправляет
POST /api/leads/hotels/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/продакшен-сервера (3001) |
VITE_SITE_URL |
нет | Канонический адрес, подставляется в canonical и og: |
VITE_BASE_PATH |
нет | Подпуть размещения, например /hotels/ (по умолчанию /) |
Структура
index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
hooks/ useReveal, useScrolled, useAnchorScroll
lib/ отправка заявки, форматирование чисел
assets/images/ 11 изображений, извлечённых из исходного HTML
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM
legacy/index.html исходный однофайловый лендинг (визуальный эталон)
public/ favicon.png и og-image.jpg
Дизайн-система
Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в
@theme внутри src/index.css. Палитра Tailwind по умолчанию отключена
(--color-*: initial), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: sm 640, md 900, плюс
max-width-варианты tiny (430), phone (480), compact (620),
narrow (780).
Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px
скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный
крупный кусок вёрстки, оставшийся классом (.scroll-cards в index.css):
grid-auto-columns не выражается утилитами, а сам ряд используется пять раз.
Соответствие исходной вёрстке
Текст страницы совпадает с legacy/index.html строка в строку (проверено
диффом innerText), высоты секций — в пределах 0,6% на 375 и 1440 px.
Расхождения дают метрики настоящего Inter: исходная страница просила Inter, но
нигде его не подключала и рисовалась системным фолбэком.
Три отличия сделаны намеренно:
.eyebrowи.leadв блоке заявки исходно оставались тёмными (#008F84и#60778B) на тёмно-синем фоне — контраст 3,4–3,8:1. Здесь они светлые, как в остальных тёмных секциях.- На экранах ≤430px значение в карточке hero-факта («4 аппарата», «1 оператор») не помещалось в колонку 80px и наезжало на подпись. Колонка расширена до 124px.
- В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «2025–2026 гг.».
Шрифты
Inter подключён локально пакетом @fontsource-variable/inter — внешних запросов
(Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном
100–900, поэтому нестандартные веса из макета (650, 740, 760, 780, 790, 820,
850) отрисовываются по-настоящему, а не округляются до bold.
Подсеты разделены по unicode-range, браузер скачивает только нужные:
latin (48 КБ), cyrillic (19 КБ) и latin-ext (85 КБ — только ради знака ₽
в калькуляторе). Итого ~152 КБ.
Символы → ↗ ↘ · не входят ни в один подсет Inter у Fontsource, поэтому для них
всегда используется системный фолбэк.
Калькулятор
программы в день × средний чек × рабочие дни = выручка в месяц
(та же формула, что в исходном exo-economics-calculators).
Известные особенности
og:imageотдаётся по абсолютному адресуVITE_SITE_URL+/og-image.jpg; при смене домена обновитеVITE_SITE_URLперед сборкой. Если сайт стоит на подпути (/hotels), файл всё равно лежит в корне — проверьте адрес после деплоя.- В
legacy/index.htmlостался комментарий о том, что названия отелей-кейсов (Marriott, Cosmosstay, «Адонис» и др.) не выводятся до подтверждения права использования брендов. В React-версии этих названий тоже нет.