A <noscript> in <head> may only contain link, style and meta, so parse5 rejected the <div><img> pixel (disallowed-content-in-noscript-in-head). Applied to hotel, fitnes, medcenterphysio and medcenterstart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ЭкзоОтель — лендинг для отелей
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 заранее не нужно.
Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны только
имя и телефон, отель, email и комментарий по желанию; сделка называется
ЭкзоОтель — <отель или имя>. Вторая — модальное окно «Перезвоним вам», его
открывает кнопка «Звонок» в шапке (то же окно, что на фитнес-лендинге): имя,
телефон и согласие на обработку данных. Сделка называется
Обратный звонок — <имя>, в поле или примечании «Форма» — «Быстрый обратный
звонок».
Если 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, useBodyLock, useEscapeKey
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 исходный однофайловый лендинг (первая версия дизайна)
legacy/hotel v7/ макет v7 — текущий визуальный эталон
public/ favicon.png и og-image.jpg
Дизайн-система
Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в
@theme внутри src/index.css. Палитра Tailwind по умолчанию отключена
(--color-*: initial), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: sm 640, md 900, плюс
max-width-варианты narrow (780), slim (640), compact (620), phone (480),
tiny (430), micro (380). Tailwind выводит пользовательские варианты в порядке
объявления, поэтому они объявлены от широкого к узкому: где действуют два,
побеждает более узкий — как в каскаде макета.
Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px
скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный
крупный кусок вёрстки, оставшийся классом (.scroll-cards в index.css):
grid-auto-columns не выражается утилитами, а сам ряд используется пять раз.
Соответствие макету
Визуальный эталон — макет legacy/hotel v7/. Сам макет Inter не подключает и
рисуется системным шрифтом, поэтому сверять его с сайтом нужно, подставив в него
Inter Variable. На одном шрифте текст страницы совпадает с макетом строка в строку
(дифф innerText), а высоты всех секций и общая высота страницы — попиксельно на
360 / 375 / 430 / 480 / 620 / 640 / 780 / 900 / 1120 / 1440 px.
Намеренные отличия от макета:
.eyebrowв блоке заявки в макете остаётся тёмным (#008F84) на тёмно-синем фоне. Здесь он светлый, как в остальных тёмных секциях.- В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «2025–2026 гг.».
- Модальное окно «Перезвоним вам» взято с фитнес-лендинга (светлые поля,
согласие отдельным чекбоксом), а не вёрстка
.exo-callbackиз макета; маски телефона, как и на фитнесе, нет. Тексты окна — из макета.
Шрифты
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-версии этих названий тоже нет.