Files
exodevices/hotel/README.md
T
Yuriy PanovandClaude Opus 5 1ee5431b92 hotel: apply the v7 design and add the callback dialog
Ports legacy/hotel v7 into the React landing.

- Copy: nav is Экономика / Для гостя / Интерьер / Сервис, and «Интерьер»
  now comes before «Сервис». The hero keeps one button and gets the
  2 500 ₽ / 600 000 ₽ / 7,2 млн ₽ facts. New headings and leads for the
  interior, service and request blocks. Session lengths 10–15 and 30–60
  min, area 12–36 m².
- Calculator counts sessions and gains the ЭкзоКлиник benchmark and a
  «Получить консультацию» button.
- Request form: company and email are optional. The server schema
  already treats them as optional.
- v8 phone type scale (≤620px), a 380px `micro` step, and a `slim`
  (≤640px) variant. The max-width variants are now declared widest first,
  so the narrower one wins where two apply.
- The market charts' SVGs stay inline, as in the mockup.

The header «Звонок» button opens the «Перезвоним вам» dialog from the
fitness landing (name, phone, consent). The server accepts form
'callback', names the deal «Обратный звонок — <имя>» and tags it
«обратный звонок». The phone link inside the dialog still reports a call
click.

With Inter substituted into the mockup, the page text matches it line for
line (except the fixed «2025–2026 гг.» typo), and every section height
matches at 360–1440 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:00:20 +06:00

198 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ЭкзоОтель — лендинг для отелей
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 заранее не нужно.
Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны только
имя и телефон, отель, 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г..» → «20252026 гг.».
* Модальное окно «Перезвоним вам» взято с фитнес-лендинга (светлые поля,
согласие отдельным чекбоксом), а не вёрстка `.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-версии этих названий тоже нет.