# ЭкзоОтель — лендинг для отелей React-приложение лендинга «ЭкзоОтель». Каждая заявка создаёт сделку в amoCRM (воронка `10980758`) и контакт, если его ещё нет. Стек: **Vite + React 19 + TypeScript + Tailwind CSS v4**, API — **Express 5**. Архитектура та же, что у лендинга для фитнес-клубов (`../fitnes`). --- ## Быстрый старт ```bash 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), поэтому оба сайта можно держать запущенными одновременно. ## Продакшен ```bash npm run build # сборка клиента в dist/client npm start # Express отдаёт dist/client и обрабатывает /api/* ``` Один процесс Node на любом VPS. Порт — `PORT` (по умолчанию 3001). --- ## amoCRM ### 1. Получить долгосрочный токен 1. amoCRM → **Настройки → Интеграции → Создать интеграцию → Внешняя интеграция**. 2. Название любое (например «Сайт hotels»), redirect URI — любой рабочий адрес сайта: для долгосрочного токена он не используется. 3. Права доступа: достаточно **Сделки, Контакты** (чтение и запись). 4. Сохранить, открыть интеграцию → вкладка **«Ключи и скопы»** → **«Генерировать токен»** (долгосрочный, действует ~1 год). 5. Скопировать токен в `.env` в `AMO_LONG_LIVED_TOKEN`, поддомен — в `AMO_SUBDOMAIN` (для `https://exotherapy.amocrm.ru` это `exotherapy`). Токен читается только на сервере и никогда не попадает в браузерный бандл. Пометьте в календаре дату истечения — токен нужно перевыпустить через год. Токен можно взять тот же, что у фитнес-лендинга: сделки различаются по тегу (`AMO_LEAD_TAGS=hotel-landing`) и по названию сделки `ЭкзоОтель — …`. ### 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 заранее не нужно. Если 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-версии этих названий тоже нет.