Ports the single-file ЭкзоОтель page to the same architecture as the fitness
landing: Vite + React 19 + TypeScript + Tailwind v4 on the client, Express 5 for
the /api/leads/hotels endpoint, zod schema shared between the two.
The original page is kept in legacy/index.html as the visual reference. Its 15
inlined base64 images are extracted to files (the 1 MB HTML becomes ~318 KB of
JS plus assets loaded on demand), and its text is reproduced line for line —
verified with an innerText diff. Section heights stay within 0.6% at 375 and
1440 px; the drift comes from Inter actually loading, which the original asked
for but never served.
Three deliberate departures, documented in the README:
* eyebrow and lead in the CTA block were dark teal on navy (3.4:1); they now
match the other dark sections
* hero fact values overflowed their 80px column into the label below 430px
* "2025–2026г.." typo in the market source note
Leads reuse the fitness amoCRM integration: contact lookup by phone in every
spelling, deal in the first stage of pipeline 10980758, account fields matched
automatically with the rest written to a note. Rate limit, honeypot, and a
localStorage fallback so a lead survives the CRM being down. Vite runs on 5174
and the API on 3001 so both landings can run at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
10 KiB
Markdown
173 lines
10 KiB
Markdown
# ЭкзоОтель — лендинг для отелей
|
||
|
||
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` браузера, а посетитель
|
||
видит телефон для связи.
|
||
|
||
### Переменные окружения
|
||
|
||
| Переменная | Обязательна | Описание |
|
||
|---|---|---|
|
||
| `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-версии этих названий тоже нет.
|