Convert hotel landing to React + Tailwind v4 with amoCRM lead capture
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>
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "hotel-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev:client"],
|
||||||
|
"port": 5174
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public (baked into the client bundle at build time — never put secrets here)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
VITE_SITE_URL=https://exodevices.ru/hotels
|
||||||
|
|
||||||
|
# Sub-path the site is served from, if any. Leave as "/" when it sits at the
|
||||||
|
# domain root; set to "/hotels/" if it is mounted under exodevices.ru/hotels.
|
||||||
|
VITE_BASE_PATH=/
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3001 by default so this landing can run next to the fitness one (3000).
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# amoCRM — server-side only, never exposed to the browser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Your account subdomain: for https://exotherapy.amocrm.ru put "exotherapy"
|
||||||
|
AMO_SUBDOMAIN=
|
||||||
|
|
||||||
|
# Long-lived access token.
|
||||||
|
# amoCRM → Настройки → Интеграции → Создать интеграцию → Внешняя интеграция →
|
||||||
|
# open it → tab "Ключи и скопы" → «Генерировать токен» (долгосрочный, ~1 год).
|
||||||
|
AMO_LONG_LIVED_TOKEN=
|
||||||
|
|
||||||
|
# Pipeline (воронка) the leads land in. The lead is placed in its first stage.
|
||||||
|
AMO_PIPELINE_ID=10980758
|
||||||
|
|
||||||
|
# Optional: numeric id of the amoCRM user leads are assigned to.
|
||||||
|
AMO_RESPONSIBLE_USER_ID=
|
||||||
|
|
||||||
|
# Optional: comma-separated tags added to every lead.
|
||||||
|
AMO_LEAD_TAGS=hotel-landing
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# ЭкзоОтель — лендинг для отелей
|
||||||
|
|
||||||
|
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-версии этих названий тоже нет.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>ЭкзоОтель — пространство восстановления для гостей | Экзо Групп</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="ЭкзоОтель — готовое пространство восстановления внутри отеля: 4 аппарата, формат с одним специалистом, форматы размещения, меню программ. Дополнительный сервис для выручки, LTV и привлечения гостей."
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#0A2540" />
|
||||||
|
<meta name="robots" content="index,follow" />
|
||||||
|
<link rel="canonical" href="%VITE_SITE_URL%" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<meta property="og:site_name" content="Экзо Групп" />
|
||||||
|
<meta property="og:title" content="ЭкзоОтель: пространство восстановления для гостей" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Готовое wellness-tech пространство для дополнительной выручки, роста ценности проживания, LTV и привлечения гостей."
|
||||||
|
/>
|
||||||
|
<meta property="og:url" content="%VITE_SITE_URL%" />
|
||||||
|
<meta property="og:image" content="%VITE_SITE_URL%/og-image.jpg" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Премиальное пространство ЭкзоОтель с оборудованием Экзо Групп" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "ЭкзоОтель — пространство восстановления для гостей отеля",
|
||||||
|
"provider": { "@type": "Organization", "name": "Экзо Групп" },
|
||||||
|
"serviceType": "Проектирование и запуск кабинета восстановления в отеле",
|
||||||
|
"areaServed": "RU",
|
||||||
|
"url": "https://exodevices.ru/hotels"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "exo-hotel-landing",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n client,server -c cyan,magenta \"vite\" \"tsx watch server/src/index.ts\"",
|
||||||
|
"dev:client": "vite",
|
||||||
|
"dev:server": "tsx watch server/src/index.ts",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "NODE_ENV=production tsx server/src/index.ts",
|
||||||
|
"typecheck": "tsc -b --force",
|
||||||
|
"amo:check": "tsx scripts/amo-check.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^26.4.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.5",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"concurrently": "^10.0.5",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Verifies the amoCRM connection and prints what the integration will do with
|
||||||
|
* your account: which pipeline stage leads land in, and which of your custom
|
||||||
|
* fields the lead form's data was matched to.
|
||||||
|
*
|
||||||
|
* npm run amo:check
|
||||||
|
*/
|
||||||
|
import { AmoClient } from '../server/src/amocrm.ts'
|
||||||
|
import { readAmoConfig } from '../server/src/config.ts'
|
||||||
|
import { resolveLeadFieldMap } from '../server/src/lead-mapper.ts'
|
||||||
|
|
||||||
|
const config = readAmoConfig()
|
||||||
|
if (!config) {
|
||||||
|
console.error('✗ AMO_SUBDOMAIN and AMO_LONG_LIVED_TOKEN must be set in .env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const amo = new AmoClient(config)
|
||||||
|
|
||||||
|
console.info(`→ ${config.baseUrl}\n`)
|
||||||
|
|
||||||
|
const account = await amo.getAccount()
|
||||||
|
console.info(`✓ Account: ${account?.name} (id ${account?.id})`)
|
||||||
|
|
||||||
|
const pipeline = await amo.getPipeline(config.pipelineId)
|
||||||
|
if (!pipeline) {
|
||||||
|
console.error(`✗ Pipeline ${config.pipelineId} not found in this account.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = [...(pipeline._embedded?.statuses ?? [])].sort((a, b) => a.sort - b.sort)
|
||||||
|
console.info(`✓ Pipeline ${pipeline.id}: «${pipeline.name}»`)
|
||||||
|
console.info(` Leads will be created in the first stage: «${stages[0]?.name ?? 'unknown'}»`)
|
||||||
|
console.info(` All stages: ${stages.map((s) => s.name).join(' → ')}\n`)
|
||||||
|
|
||||||
|
const leadFields = await amo.getLeadFields()
|
||||||
|
const map = resolveLeadFieldMap(leadFields)
|
||||||
|
|
||||||
|
console.info(`Lead data → amoCRM field mapping (${leadFields.length} custom fields in the account):`)
|
||||||
|
const keys = [
|
||||||
|
'company',
|
||||||
|
'comment',
|
||||||
|
'page',
|
||||||
|
'referrer',
|
||||||
|
'form',
|
||||||
|
'utm_source',
|
||||||
|
'utm_medium',
|
||||||
|
'utm_campaign',
|
||||||
|
'utm_content',
|
||||||
|
'utm_term',
|
||||||
|
]
|
||||||
|
for (const key of keys) {
|
||||||
|
const field = map.get(key)
|
||||||
|
console.info(
|
||||||
|
field
|
||||||
|
? ` ✓ ${key.padEnd(14)} → «${field.name}» (id ${field.id}, ${field.type})`
|
||||||
|
: ` · ${key.padEnd(14)} → note on the lead`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactFields = await amo.getContactFields()
|
||||||
|
const hasPhone = contactFields.some((f) => f.code === 'PHONE')
|
||||||
|
const hasEmail = contactFields.some((f) => f.code === 'EMAIL')
|
||||||
|
console.info(`\nContact fields: PHONE ${hasPhone ? '✓' : '✗ missing'}, EMAIL ${hasEmail ? '✓' : '✗ missing'}`)
|
||||||
|
console.info('\nAll good — the form is ready to create leads.')
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import type { AmoConfig } from './config.ts'
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* amoCRM REST types (only the parts this integration touches) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface AmoEnum {
|
||||||
|
id: number
|
||||||
|
value: string
|
||||||
|
enum_code?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmoCustomField {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string | null
|
||||||
|
type: string
|
||||||
|
enums?: AmoEnum[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AmoFieldValue {
|
||||||
|
value: string | number | boolean | null
|
||||||
|
enum_id?: number
|
||||||
|
enum_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmoFieldEntry {
|
||||||
|
field_id?: number
|
||||||
|
field_code?: string
|
||||||
|
values: AmoFieldValue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmoContact {
|
||||||
|
id: number
|
||||||
|
name?: string
|
||||||
|
custom_fields_values?: AmoFieldEntry[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmoLead {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AmoError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status?: number,
|
||||||
|
readonly detail?: unknown,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AmoError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Client */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const RETRYABLE = new Set([429, 500, 502, 503, 504])
|
||||||
|
|
||||||
|
export class AmoClient {
|
||||||
|
private leadFields?: Promise<AmoCustomField[]>
|
||||||
|
private contactFields?: Promise<AmoCustomField[]>
|
||||||
|
|
||||||
|
constructor(private readonly config: AmoConfig) {}
|
||||||
|
|
||||||
|
get baseUrl(): string {
|
||||||
|
return this.config.baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses
|
||||||
|
* for "found nothing" on every collection endpoint.
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
|
||||||
|
attempt = 1,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const url = new URL(path, this.config.baseUrl)
|
||||||
|
for (const [key, value] of Object.entries(init.query ?? {})) {
|
||||||
|
if (value !== undefined) url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: init.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
} catch (cause) {
|
||||||
|
if (attempt < 3) {
|
||||||
|
await sleep(attempt * 400)
|
||||||
|
return this.request<T>(path, init, attempt + 1)
|
||||||
|
}
|
||||||
|
throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => '')
|
||||||
|
if (RETRYABLE.has(response.status) && attempt < 3) {
|
||||||
|
await sleep(attempt * 700)
|
||||||
|
return this.request<T>(path, init, attempt + 1)
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail)
|
||||||
|
}
|
||||||
|
throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Field metadata — fetched once per process, then reused */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
private async fetchFields(entity: 'leads' | 'contacts'): Promise<AmoCustomField[]> {
|
||||||
|
const collected: AmoCustomField[] = []
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>(
|
||||||
|
`/api/v4/${entity}/custom_fields`,
|
||||||
|
{ query: { page, limit: 250 } },
|
||||||
|
)
|
||||||
|
const batch = res?._embedded?.custom_fields ?? []
|
||||||
|
collected.push(...batch)
|
||||||
|
if (batch.length < 250) break
|
||||||
|
}
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeadFields(): Promise<AmoCustomField[]> {
|
||||||
|
this.leadFields ??= this.fetchFields('leads')
|
||||||
|
return this.leadFields
|
||||||
|
}
|
||||||
|
|
||||||
|
getContactFields(): Promise<AmoCustomField[]> {
|
||||||
|
this.contactFields ??= this.fetchFields('contacts')
|
||||||
|
return this.contactFields
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Contacts */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Full-text search. amoCRM matches phones and emails regardless of format. */
|
||||||
|
async findContact(queries: string[]): Promise<AmoContact | null> {
|
||||||
|
for (const query of queries) {
|
||||||
|
if (!query) continue
|
||||||
|
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
|
||||||
|
query: { query, limit: 1, with: 'leads' },
|
||||||
|
})
|
||||||
|
const contact = res?._embedded?.contacts?.[0]
|
||||||
|
if (contact) return contact
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise<AmoContact> {
|
||||||
|
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
name: input.name,
|
||||||
|
responsible_user_id: this.config.responsibleUserId,
|
||||||
|
custom_fields_values: input.fields.length ? input.fields : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const contact = res?._embedded?.contacts?.[0]
|
||||||
|
if (!contact) throw new AmoError('amoCRM did not return the created contact')
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds phone/email to an existing contact without dropping what is there. */
|
||||||
|
async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise<void> {
|
||||||
|
if (!fields.length) return
|
||||||
|
await this.request(`/api/v4/contacts/${contactId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { custom_fields_values: fields },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Leads */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the lead in the configured pipeline. Omitting `status_id` makes
|
||||||
|
* amoCRM drop it into that pipeline's first stage, which is what we want.
|
||||||
|
*/
|
||||||
|
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
|
||||||
|
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] }
|
||||||
|
const tags = [...new Set([...this.config.tags, ...input.tags])]
|
||||||
|
if (tags.length) embedded.tags = tags.map((name) => ({ name }))
|
||||||
|
|
||||||
|
const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
name: input.name,
|
||||||
|
pipeline_id: this.config.pipelineId,
|
||||||
|
responsible_user_id: this.config.responsibleUserId,
|
||||||
|
custom_fields_values: input.fields.length ? input.fields : undefined,
|
||||||
|
_embedded: embedded,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const lead = res?._embedded?.leads?.[0]
|
||||||
|
if (!lead) throw new AmoError('amoCRM did not return the created lead')
|
||||||
|
return lead
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLeadNote(leadId: number, text: string): Promise<void> {
|
||||||
|
await this.request(`/api/v4/leads/${leadId}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: [{ note_type: 'common', params: { text } }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Diagnostics */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> {
|
||||||
|
return this.request('/api/v4/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPipeline(id: number): Promise<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
_embedded?: { statuses?: { id: number; name: string; sort: number }[] }
|
||||||
|
} | null> {
|
||||||
|
return this.request(`/api/v4/leads/pipelines/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { config as loadEnv } from 'dotenv'
|
||||||
|
|
||||||
|
loadEnv()
|
||||||
|
|
||||||
|
function optional(name: string): string | undefined {
|
||||||
|
const value = process.env[name]?.trim()
|
||||||
|
return value ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(name: string): number | undefined {
|
||||||
|
const raw = optional(name)
|
||||||
|
if (raw === undefined) return undefined
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isFinite(value)) throw new Error(`${name} must be a number, got "${raw}"`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* amoCRM settings. Deliberately not throwing when they are missing: the site
|
||||||
|
* must still boot and serve pages without a CRM connection — only the lead
|
||||||
|
* endpoint degrades, and it says so explicitly.
|
||||||
|
*/
|
||||||
|
export interface AmoConfig {
|
||||||
|
baseUrl: string
|
||||||
|
token: string
|
||||||
|
pipelineId: number
|
||||||
|
responsibleUserId?: number
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAmoConfig(): AmoConfig | null {
|
||||||
|
const subdomain = optional('AMO_SUBDOMAIN')
|
||||||
|
const token = optional('AMO_LONG_LIVED_TOKEN')
|
||||||
|
if (!subdomain || !token) return null
|
||||||
|
|
||||||
|
// Accepts "exotherapy", "exotherapy.amocrm.ru", or a full URL (an explicit
|
||||||
|
// scheme is kept as-is, which is what local mocks and self-hosted setups need).
|
||||||
|
const host = subdomain.replace(/\/+$/, '')
|
||||||
|
const baseUrl = /^https?:\/\//.test(host)
|
||||||
|
? host
|
||||||
|
: `https://${host.includes('.') ? host : `${host}.${optional('AMO_DOMAIN') ?? 'amocrm.ru'}`}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
pipelineId: optionalNumber('AMO_PIPELINE_ID') ?? 10980758,
|
||||||
|
responsibleUserId: optionalNumber('AMO_RESPONSIBLE_USER_ID'),
|
||||||
|
tags: (optional('AMO_LEAD_TAGS') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverConfig = {
|
||||||
|
port: optionalNumber('PORT') ?? 3001,
|
||||||
|
isProduction: process.env.NODE_ENV === 'production',
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import express from 'express'
|
||||||
|
import { leadSchema, type LeadResponse } from '../../shared/lead.ts'
|
||||||
|
import { AmoClient, AmoError } from './amocrm.ts'
|
||||||
|
import { readAmoConfig, serverConfig } from './config.ts'
|
||||||
|
import { LeadService } from './lead-service.ts'
|
||||||
|
import { createRateLimiter } from './rate-limit.ts'
|
||||||
|
|
||||||
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||||
|
const clientDir = path.join(rootDir, 'dist/client')
|
||||||
|
|
||||||
|
const amoConfig = readAmoConfig()
|
||||||
|
const leadService = amoConfig ? new LeadService(new AmoClient(amoConfig)) : null
|
||||||
|
|
||||||
|
if (!leadService) {
|
||||||
|
console.warn(
|
||||||
|
'[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN are not set — the site runs, but /api/leads/* will return 503.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.set('trust proxy', true)
|
||||||
|
app.use(express.json({ limit: '64kb' }))
|
||||||
|
|
||||||
|
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/leads/hotels', async (req, res) => {
|
||||||
|
const send = (status: number, body: LeadResponse) => res.status(status).json(body)
|
||||||
|
|
||||||
|
const parsed = leadSchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
const fields: Record<string, string> = {}
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
const key = issue.path.join('.')
|
||||||
|
if (key && !fields[key]) fields[key] = issue.message
|
||||||
|
}
|
||||||
|
return send(400, { ok: false, error: 'Проверьте заполненные поля.', fields })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed.data
|
||||||
|
|
||||||
|
// Honeypot: answer exactly like a success so bots learn nothing.
|
||||||
|
if (payload.website) {
|
||||||
|
console.info('[lead] honeypot triggered from %s', req.ip)
|
||||||
|
return send(200, { ok: true, leadId: 0, contactId: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { allowed, retryAfterSeconds } = limiter(req.ip ?? 'unknown')
|
||||||
|
if (!allowed) {
|
||||||
|
res.setHeader('Retry-After', String(retryAfterSeconds))
|
||||||
|
return send(429, { ok: false, error: 'Слишком много заявок с этого адреса. Попробуйте позже.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leadService) {
|
||||||
|
// Never drop a real lead silently — it must be findable in the logs.
|
||||||
|
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
|
||||||
|
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await leadService.submit(payload)
|
||||||
|
console.info(
|
||||||
|
'[lead] amoCRM lead %d (contact %d, %s)',
|
||||||
|
result.leadId,
|
||||||
|
result.contactId,
|
||||||
|
result.contactCreated ? 'new contact' : 'existing contact',
|
||||||
|
)
|
||||||
|
return send(200, { ok: true, leadId: result.leadId, contactId: result.contactId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[lead] amoCRM submission failed:', error)
|
||||||
|
console.error('[lead] payload was:', JSON.stringify(payload))
|
||||||
|
const message =
|
||||||
|
error instanceof AmoError && error.status === 401
|
||||||
|
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.'
|
||||||
|
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.'
|
||||||
|
return send(502, { ok: false, error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (serverConfig.isProduction) {
|
||||||
|
app.use(
|
||||||
|
express.static(clientDir, {
|
||||||
|
index: false,
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
// Vite fingerprints everything under /assets, so it can be cached hard.
|
||||||
|
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// SPA fallback. Written as middleware because Express 5 no longer accepts a
|
||||||
|
// bare '*' route pattern.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
|
||||||
|
res.sendFile(path.join(clientDir, 'index.html'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(serverConfig.port, () => {
|
||||||
|
console.info(
|
||||||
|
'[server] listening on http://localhost:%d%s',
|
||||||
|
serverConfig.port,
|
||||||
|
serverConfig.isProduction ? ` (serving ${path.relative(rootDir, clientDir)})` : ' (API only — run vite for the UI)',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { LeadPayload } from '../../shared/lead.ts'
|
||||||
|
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
|
||||||
|
|
||||||
|
/** Human labels used in the fallback note. */
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
company: 'Отель / сеть',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Телефон',
|
||||||
|
comment: 'Комментарий',
|
||||||
|
page: 'Страница',
|
||||||
|
referrer: 'Источник перехода',
|
||||||
|
form: 'Форма',
|
||||||
|
utm_source: 'utm_source',
|
||||||
|
utm_medium: 'utm_medium',
|
||||||
|
utm_campaign: 'utm_campaign',
|
||||||
|
utm_content: 'utm_content',
|
||||||
|
utm_term: 'utm_term',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate amoCRM field codes / name fragments for each piece of data we
|
||||||
|
* collect. The first field in the account that matches wins; anything without
|
||||||
|
* a match falls through to the lead note instead.
|
||||||
|
*/
|
||||||
|
const CANDIDATES: Record<string, { codes: string[]; names: string[] }> = {
|
||||||
|
company: {
|
||||||
|
codes: ['COMPANY', 'COMPANY_NAME'],
|
||||||
|
names: ['компания', 'организация', 'название отеля', 'отель', 'гостиница', 'сеть'],
|
||||||
|
},
|
||||||
|
comment: { codes: ['COMMENT', 'MESSAGE', 'DESCRIPTION'], names: ['комментарий', 'сообщение', 'описание заявки'] },
|
||||||
|
page: { codes: ['REFERRER', 'PAGE'], names: ['страница', 'посадочная страница', 'url страницы'] },
|
||||||
|
referrer: { codes: ['REFERER_URL'], names: ['источник перехода', 'referrer', 'реферер'] },
|
||||||
|
form: { codes: ['FORMNAME', 'FORM_NAME'], names: ['название формы', 'форма'] },
|
||||||
|
utm_source: { codes: ['UTM_SOURCE'], names: ['utm_source'] },
|
||||||
|
utm_medium: { codes: ['UTM_MEDIUM'], names: ['utm_medium'] },
|
||||||
|
utm_campaign: { codes: ['UTM_CAMPAIGN'], names: ['utm_campaign'] },
|
||||||
|
utm_content: { codes: ['UTM_CONTENT'], names: ['utm_content'] },
|
||||||
|
utm_term: { codes: ['UTM_TERM'], names: ['utm_term'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field types we know how to write a plain string into. */
|
||||||
|
const TEXTUAL = new Set(['text', 'textarea', 'url', 'tracking_data', 'numeric', 'price', 'monetary'])
|
||||||
|
const ENUMERATED = new Set(['select', 'radiobutton', 'multiselect'])
|
||||||
|
|
||||||
|
const normalise = (value: string) =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, 'е')
|
||||||
|
.replace(/[^a-zа-я0-9]+/gi, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
export type LeadFieldMap = Map<string, AmoCustomField>
|
||||||
|
|
||||||
|
/** Picks one account field per payload key. A field is never used twice. */
|
||||||
|
export function resolveLeadFieldMap(fields: AmoCustomField[]): LeadFieldMap {
|
||||||
|
const map: LeadFieldMap = new Map()
|
||||||
|
const taken = new Set<number>()
|
||||||
|
|
||||||
|
for (const [key, candidate] of Object.entries(CANDIDATES)) {
|
||||||
|
const byCode = fields.find(
|
||||||
|
(f) => f.code && candidate.codes.includes(f.code.toUpperCase()) && !taken.has(f.id),
|
||||||
|
)
|
||||||
|
const match =
|
||||||
|
byCode ??
|
||||||
|
fields.find((f) => {
|
||||||
|
if (taken.has(f.id)) return false
|
||||||
|
const name = normalise(f.name)
|
||||||
|
return candidate.names.some((n) => name === normalise(n))
|
||||||
|
}) ??
|
||||||
|
fields.find((f) => {
|
||||||
|
if (taken.has(f.id)) return false
|
||||||
|
const name = normalise(f.name)
|
||||||
|
return candidate.names.some((n) => name.includes(normalise(n)))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!match) continue
|
||||||
|
if (!TEXTUAL.has(match.type) && !ENUMERATED.has(match.type)) continue
|
||||||
|
map.set(key, match)
|
||||||
|
taken.add(match.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds one amoCRM field entry, or null when the value cannot be represented. */
|
||||||
|
function toFieldEntry(field: AmoCustomField, value: string): AmoFieldEntry | null {
|
||||||
|
if (ENUMERATED.has(field.type)) {
|
||||||
|
const target = normalise(value)
|
||||||
|
const option = field.enums?.find((e) => normalise(e.value) === target)
|
||||||
|
// An unmatched option would silently create garbage, so let it hit the note.
|
||||||
|
return option ? { field_id: field.id, values: [{ value: option.value, enum_id: option.id }] } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'numeric' || field.type === 'price' || field.type === 'monetary') {
|
||||||
|
const numeric = Number(value.replace(/[^\d.,-]/g, '').replace(',', '.'))
|
||||||
|
return Number.isFinite(numeric) ? { field_id: field.id, values: [{ value: numeric }] } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { field_id: field.id, values: [{ value }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MappedLead {
|
||||||
|
fields: AmoFieldEntry[]
|
||||||
|
/** Everything that had no matching field, ready to be written as a note. */
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
|
||||||
|
const data: Record<string, string | undefined> = {
|
||||||
|
company: payload.company,
|
||||||
|
comment: payload.comment,
|
||||||
|
page: payload.page,
|
||||||
|
referrer: payload.referrer,
|
||||||
|
form: 'Заявка на предложение',
|
||||||
|
...payload.utm,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: AmoFieldEntry[] = []
|
||||||
|
const leftovers: string[] = []
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (!value) continue
|
||||||
|
const field = fieldMap.get(key)
|
||||||
|
const entry = field ? toFieldEntry(field, value) : null
|
||||||
|
if (entry) fields.push(entry)
|
||||||
|
else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact details are always repeated in the note so a manager can read the
|
||||||
|
// whole request without opening the linked contact card.
|
||||||
|
const header = [
|
||||||
|
'Заявка с лендинга «ЭкзоОтель»',
|
||||||
|
`${LABELS.phone}: ${payload.phone}`,
|
||||||
|
payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
return { fields, note: [...header, ...leftovers].join('\n') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lead title shown in the pipeline. */
|
||||||
|
export function buildLeadName(payload: LeadPayload): string {
|
||||||
|
return `ЭкзоОтель — ${payload.company ?? payload.name}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { LeadPayload } from '../../shared/lead.ts'
|
||||||
|
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
|
||||||
|
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts'
|
||||||
|
|
||||||
|
const digitsOnly = (value: string) => value.replace(/\D/g, '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last ten digits — the part that is stable across "+7 (999) 123-45-67",
|
||||||
|
* "8 999 1234567" and "9991234567". Used to decide whether amoCRM already
|
||||||
|
* knows a phone number.
|
||||||
|
*/
|
||||||
|
const phoneKey = (value: string) => digitsOnly(value).slice(-10)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* amoCRM's full-text search matches digit substrings, but a contact stored as
|
||||||
|
* "+7 999…" is not found by a query starting with "8 999…". Try every spelling
|
||||||
|
* a Russian visitor might type.
|
||||||
|
*/
|
||||||
|
function phoneQueries(phone: string): string[] {
|
||||||
|
const digits = digitsOnly(phone)
|
||||||
|
const queries = new Set<string>([phone.trim(), digits])
|
||||||
|
if (digits.length >= 10) {
|
||||||
|
const national = digits.slice(-10)
|
||||||
|
queries.add(national)
|
||||||
|
queries.add(`7${national}`)
|
||||||
|
queries.add(`8${national}`)
|
||||||
|
}
|
||||||
|
return [...queries].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the values already stored in a contact's standard multitext field. */
|
||||||
|
function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] {
|
||||||
|
const entry = contact.custom_fields_values?.find((f) => f.field_code === code)
|
||||||
|
return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] {
|
||||||
|
const entries: AmoFieldEntry[] = [
|
||||||
|
{ field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] },
|
||||||
|
]
|
||||||
|
if (payload.email) {
|
||||||
|
entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedLead {
|
||||||
|
leadId: number
|
||||||
|
contactId: number
|
||||||
|
contactCreated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeadService {
|
||||||
|
private fieldMap?: Promise<LeadFieldMap>
|
||||||
|
|
||||||
|
constructor(private readonly amo: AmoClient) {}
|
||||||
|
|
||||||
|
private getFieldMap(): Promise<LeadFieldMap> {
|
||||||
|
this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
|
||||||
|
return this.fieldMap
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(payload: LeadPayload): Promise<CreatedLead> {
|
||||||
|
const fieldMap = await this.getFieldMap()
|
||||||
|
|
||||||
|
// 1. Reuse the existing contact when the phone or email is already known.
|
||||||
|
const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? ''])
|
||||||
|
|
||||||
|
let contactId: number
|
||||||
|
let contactCreated = false
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
contactId = existing.id
|
||||||
|
// Add whichever channel amoCRM does not have on file yet.
|
||||||
|
const missing: AmoFieldEntry[] = []
|
||||||
|
const knownPhones = existingValues(existing, 'PHONE').map(phoneKey)
|
||||||
|
if (!knownPhones.includes(phoneKey(payload.phone))) {
|
||||||
|
missing.push({
|
||||||
|
field_code: 'PHONE',
|
||||||
|
values: [
|
||||||
|
...existingValues(existing, 'PHONE').map((value) => ({ value })),
|
||||||
|
{ value: payload.phone, enum_code: 'WORK' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (payload.email) {
|
||||||
|
const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase())
|
||||||
|
if (!knownEmails.includes(payload.email.toLowerCase())) {
|
||||||
|
missing.push({
|
||||||
|
field_code: 'EMAIL',
|
||||||
|
values: [
|
||||||
|
...existingValues(existing, 'EMAIL').map((value) => ({ value })),
|
||||||
|
{ value: payload.email, enum_code: 'WORK' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.amo.appendContactFields(contactId, missing)
|
||||||
|
} else {
|
||||||
|
const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) })
|
||||||
|
contactId = created.id
|
||||||
|
contactCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lead in the configured pipeline, first stage.
|
||||||
|
const { fields, note } = mapLead(payload, fieldMap)
|
||||||
|
const lead = await this.amo.createLead({
|
||||||
|
name: buildLeadName(payload),
|
||||||
|
contactId,
|
||||||
|
fields,
|
||||||
|
tags: ['заявка с сайта'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Everything the account has no field for lands in a readable note.
|
||||||
|
// A failed note must not fail the lead — the lead is the valuable part.
|
||||||
|
if (note) {
|
||||||
|
await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => {
|
||||||
|
console.warn('[amo] lead %d created but the note failed:', lead.id, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { leadId: lead.id, contactId, contactCreated }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
interface Bucket {
|
||||||
|
count: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fixed-window limiter for the public lead endpoint. In-memory on
|
||||||
|
* purpose: a single landing page behind one Node process does not warrant a
|
||||||
|
* shared store, and losing the counters on restart is harmless.
|
||||||
|
*/
|
||||||
|
export function createRateLimiter(options: { limit: number; windowMs: number }) {
|
||||||
|
const buckets = new Map<string, Bucket>()
|
||||||
|
|
||||||
|
return function check(key: string): { allowed: boolean; retryAfterSeconds: number } {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (buckets.size > 5000) {
|
||||||
|
for (const [k, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = buckets.get(key)
|
||||||
|
if (!bucket || bucket.resetAt <= now) {
|
||||||
|
buckets.set(key, { count: 1, resetAt: now + options.windowMs })
|
||||||
|
return { allowed: true, retryAfterSeconds: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.count += 1
|
||||||
|
return {
|
||||||
|
allowed: bucket.count <= options.limit,
|
||||||
|
retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
|
||||||
|
export const leadFormIds = ['request'] as const
|
||||||
|
export type LeadFormId = (typeof leadFormIds)[number]
|
||||||
|
|
||||||
|
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
|
||||||
|
|
||||||
|
const optionalText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined))
|
||||||
|
|
||||||
|
export const leadSchema = z.object({
|
||||||
|
form: z.enum(leadFormIds),
|
||||||
|
name: z.string().trim().min(1, 'Укажите имя').max(200),
|
||||||
|
phone: z.string().trim().min(5, 'Укажите телефон').max(50),
|
||||||
|
email: z
|
||||||
|
.union([z.email().max(200), z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
/** Hotel or hotel chain the request comes from. */
|
||||||
|
company: optionalText(200),
|
||||||
|
comment: optionalText(4000),
|
||||||
|
page: optionalText(300),
|
||||||
|
referrer: optionalText(500),
|
||||||
|
// partialRecord, not record: z.record over an enum requires every key.
|
||||||
|
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
|
||||||
|
/** Honeypot. Bots fill it in; humans never see it. */
|
||||||
|
website: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type LeadPayload = z.infer<typeof leadSchema>
|
||||||
|
/** What the client sends — before zod's optional/empty-string normalisation. */
|
||||||
|
export type LeadInput = z.input<typeof leadSchema>
|
||||||
|
|
||||||
|
export type LeadResponse =
|
||||||
|
| { ok: true; leadId: number; contactId: number }
|
||||||
|
| { ok: false; error: string; fields?: Record<string, string> }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { BenefitsSection } from './components/BenefitsSection'
|
||||||
|
import { EconomicsSection } from './components/EconomicsSection'
|
||||||
|
import { EquipmentSection } from './components/EquipmentSection'
|
||||||
|
import { FormatsSection } from './components/FormatsSection'
|
||||||
|
import { GuestsSection } from './components/GuestsSection'
|
||||||
|
import { Hero } from './components/Hero'
|
||||||
|
import { RequestSection } from './components/RequestSection'
|
||||||
|
import { SiteFooter } from './components/SiteFooter'
|
||||||
|
import { SiteHeader } from './components/SiteHeader'
|
||||||
|
import { WhyExoSection } from './components/WhyExoSection'
|
||||||
|
import { useAnchorScroll } from './hooks/useAnchorScroll'
|
||||||
|
import { useScrolled } from './hooks/useScrollState'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const headerRef = useRef<HTMLElement>(null)
|
||||||
|
const scrolled = useScrolled()
|
||||||
|
useAnchorScroll(headerRef)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteHeader scrolled={scrolled} headerRef={headerRef} />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<BenefitsSection />
|
||||||
|
<EconomicsSection />
|
||||||
|
<GuestsSection />
|
||||||
|
<EquipmentSection />
|
||||||
|
<FormatsSection />
|
||||||
|
<WhyExoSection />
|
||||||
|
<RequestSection />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,202 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { benefits, glossary, marketImplications } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { Container, ScrollHint, Section, SectionHead } from './Layout'
|
||||||
|
|
||||||
|
export function BenefitsSection() {
|
||||||
|
return (
|
||||||
|
<Section id="benefits">
|
||||||
|
<Container>
|
||||||
|
<SectionHead
|
||||||
|
split
|
||||||
|
eyebrow="Бизнес-логика"
|
||||||
|
title="Почему это выгодно именно вашему отелю"
|
||||||
|
lead="Монетизируйте усталость гостя в моменте её проявления. Зачастую гость не планирует расслабление за месяц до отпуска. Эта потребность рождается внутри отеля — после активного дня или дороги, позволяя вам собирать дополнительную выручку без сложных предварительных продаж."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div aria-label="Преимущества для отеля" className="scroll-cards gap-[16px] md:grid-cols-4">
|
||||||
|
{benefits.map((benefit, index) => (
|
||||||
|
<BenefitCard key={benefit.num} {...benefit} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollHint>Листайте карточки в сторону</ScrollHint>
|
||||||
|
|
||||||
|
<MarketPanel />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BenefitCard({ num, title, text, index }: (typeof benefits)[number] & { index: number }) {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={ref}
|
||||||
|
style={{ transitionDelay: `${index * 80}ms` }}
|
||||||
|
className={cx(
|
||||||
|
'relative min-h-[190px] overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[25px] shadow-[0_15px_50px_rgb(10_37_64/0.055)]',
|
||||||
|
"after:absolute after:-right-[50px] after:-bottom-[65px] after:size-[150px] after:rounded-full after:bg-teal/[0.1] after:blur-[4px] after:content-['']",
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="mb-[28px] block text-[13px] font-extrabold tracking-[0.12em] text-teal">{num}</span>
|
||||||
|
<h3 className="max-w-[290px]">{title}</h3>
|
||||||
|
<p className="mb-0 text-[14px] text-muted">{text}</p>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The market panel. Its charts animate off the `.is-visible` class this block
|
||||||
|
* gets when it scrolls in — see the `.chart-*` rules in index.css.
|
||||||
|
*/
|
||||||
|
function MarketPanel() {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
aria-label="Рынок отелей 2025–2026"
|
||||||
|
className={cx(
|
||||||
|
'market-panel-surface mt-[24px] rounded-xl p-[clamp(22px,3vw,34px)] text-white shadow-deep',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-[28px] grid gap-[12px] md:grid-cols-[1fr_1.15fr] md:items-end">
|
||||||
|
<h3 className="mb-0 text-[clamp(24px,3vw,34px)]">
|
||||||
|
Загрузка отелей снижается — ценность каждого гостя становится важнее
|
||||||
|
</h3>
|
||||||
|
<p className="mb-0 max-w-[760px] text-white/[0.68]">
|
||||||
|
Гостевой поток ещё растёт, но предложение увеличивается быстрее, средняя загрузка в прогнозе 2026г. снижается
|
||||||
|
с 62,0% до 59,1%, а рост RevPAR отстаёт от роста ADR. Отелю сложнее расти только за счёт номера.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-[10px] sm:gap-[14px] md:grid-cols-4 tiny:grid-cols-1">
|
||||||
|
<MetricCard label="Гостевой поток, 2025" value="92 млн" foot={['2021 · 48', '+2,2% г/г']}>
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
aria-label="Рост гостевого потока с 48 до 92 миллионов гостей с 2021 по 2025 год"
|
||||||
|
viewBox="0 0 280 90"
|
||||||
|
>
|
||||||
|
<line className="chart-gridline" x1="8" x2="272" y1="74" y2="74" />
|
||||||
|
<line className="chart-gridline" x1="8" x2="272" y1="42" y2="42" />
|
||||||
|
<polyline className="chart-line" points="14,69 76,47 138,31 200,22 266,18" />
|
||||||
|
<circle className="chart-dot" cx="14" cy="69" r="3" />
|
||||||
|
<circle className="chart-dot" cx="76" cy="47" r="3" />
|
||||||
|
<circle className="chart-dot" cx="138" cy="31" r="3" />
|
||||||
|
<circle className="chart-dot" cx="200" cy="22" r="3" />
|
||||||
|
<circle className="chart-dot" cx="266" cy="18" r="4" />
|
||||||
|
</svg>
|
||||||
|
</MetricCard>
|
||||||
|
|
||||||
|
<MetricCard label="Предложение на рынке" value="+14%" foot={['2024', '2025 · тыс. объектов']}>
|
||||||
|
<svg role="img" aria-label="Рост предложения с более чем 33 до 38 тысяч объектов" viewBox="0 0 280 90">
|
||||||
|
<line className="chart-gridline" x1="8" x2="272" y1="78" y2="78" />
|
||||||
|
<rect className="chart-bar chart-bar--muted" height="51" rx="7" width="62" x="58" y="27" />
|
||||||
|
<rect className="chart-bar" height="60" rx="7" width="62" x="165" y="18" />
|
||||||
|
<text fill="rgba(255,255,255,.58)" fontSize="10" textAnchor="middle" x="89" y="23">
|
||||||
|
>33
|
||||||
|
</text>
|
||||||
|
<text fill="#39E0D2" fontSize="10" textAnchor="middle" x="196" y="14">
|
||||||
|
38
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</MetricCard>
|
||||||
|
|
||||||
|
<MetricCard label="Средняя загрузка, 2026г." value="−2,9 п.п." foot={['2025г.', '2026г.']}>
|
||||||
|
<svg role="img" aria-label="Снижение средней загрузки с 62,0 до 59,1 процента" viewBox="0 0 280 90">
|
||||||
|
<line className="chart-gridline" x1="8" x2="272" y1="78" y2="78" />
|
||||||
|
<rect className="chart-bar chart-bar--muted" height="63" rx="7" width="62" x="58" y="15" />
|
||||||
|
<rect className="chart-bar" height="58" rx="7" width="62" x="165" y="20" />
|
||||||
|
<text fill="rgba(255,255,255,.58)" fontSize="10" textAnchor="middle" x="89" y="12">
|
||||||
|
62,0%
|
||||||
|
</text>
|
||||||
|
<text fill="#39E0D2" fontSize="10" textAnchor="middle" x="196" y="17">
|
||||||
|
59,1%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</MetricCard>
|
||||||
|
|
||||||
|
<MetricCard label="Доходность номера, 2026г." value="разрыв роста" foot={['ADR', 'RevPAR']}>
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
aria-label="Рост средней цены номера на 5,2 процента и выручки на номер на 2,5 процента"
|
||||||
|
viewBox="0 0 280 90"
|
||||||
|
>
|
||||||
|
<line className="chart-gridline" x1="8" x2="272" y1="78" y2="78" />
|
||||||
|
<rect className="chart-bar chart-bar--muted" height="60" rx="7" width="62" x="58" y="18" />
|
||||||
|
<rect className="chart-bar" height="29" rx="7" width="62" x="165" y="49" />
|
||||||
|
<text fill="rgba(255,255,255,.58)" fontSize="10" textAnchor="middle" x="89" y="15">
|
||||||
|
+5,2%
|
||||||
|
</text>
|
||||||
|
<text fill="#39E0D2" fontSize="10" textAnchor="middle" x="196" y="46">
|
||||||
|
+2,5%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Что снижение загрузки означает для бизнеса отеля"
|
||||||
|
className="mt-[16px] grid gap-[10px] md:grid-cols-3"
|
||||||
|
>
|
||||||
|
{marketImplications.map((item) => (
|
||||||
|
<div key={item.title} className="rounded-[17px] border border-white/[0.11] bg-white/[0.055] p-[16px]">
|
||||||
|
<strong className="mb-[5px] block text-[13px] tracking-[0.02em] text-teal-bright">{item.title}</strong>
|
||||||
|
<span className="block text-[13px] leading-[1.5] text-white/[0.67]">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-[14px] mb-0 text-[11px] text-white/[0.42]">
|
||||||
|
Показатели и формулировки — из презентации «ЭкзоОтель», 2025–2026 гг. Влияние на LTV и привлечение гостей
|
||||||
|
зависит от упаковки, продвижения и операционной загрузки.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Расшифровка гостиничных показателей"
|
||||||
|
className="mt-[14px] grid gap-[8px] rounded-[16px] border border-navy/[0.1] bg-white px-[16px] py-[14px]"
|
||||||
|
>
|
||||||
|
{glossary.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.term}
|
||||||
|
className="grid grid-cols-[74px_1fr] items-start gap-[10px] text-[12px] leading-[1.45] text-muted narrow:grid-cols-1 narrow:gap-[2px]"
|
||||||
|
>
|
||||||
|
<strong className="font-[820] text-navy">{item.term}</strong>
|
||||||
|
<span>{item.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
foot,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
foot: [string, string]
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="min-h-[190px] overflow-hidden rounded-[19px] border border-white/[0.12] bg-white/[0.07] p-[18px] [&>svg]:h-[88px] [&>svg]:w-full [&>svg]:overflow-visible">
|
||||||
|
<div className="flex min-h-[44px] items-start justify-between gap-[8px] text-[12px] font-[650] text-white/[0.72]">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="flex justify-between gap-[8px] text-[10px] text-white/[0.44]">
|
||||||
|
<span>{foot[0]}</span>
|
||||||
|
<span>{foot[1]}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
|
||||||
|
export type ButtonVariant = 'primary' | 'secondary' | 'call'
|
||||||
|
|
||||||
|
const base =
|
||||||
|
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[760] leading-none whitespace-nowrap ' +
|
||||||
|
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
|
||||||
|
'[&>svg]:size-[18px] [&>svg]:shrink-0 tiny:w-full'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
|
||||||
|
* conflicting utilities by their order in the stylesheet, not by the order
|
||||||
|
* they appear in `className`, so a `min-h-[44px]` passed next to the default
|
||||||
|
* `min-h-[52px]` would silently lose. Replacing the slot means the losing
|
||||||
|
* class is never emitted in the first place.
|
||||||
|
*/
|
||||||
|
const defaultSize = 'min-h-[52px] px-[22px]'
|
||||||
|
|
||||||
|
/** `.btn--header` — smaller, and never stretched to the full width on phones. */
|
||||||
|
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] tiny:w-auto'
|
||||||
|
|
||||||
|
const variants: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
'bg-teal text-navy shadow-[0_16px_45px_rgb(0_196_180/0.26)] hover:bg-teal-bright hover:shadow-[0_20px_55px_rgb(0_196_180/0.34)]',
|
||||||
|
secondary:
|
||||||
|
'border-white/[0.26] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.42] hover:bg-white/[0.14]',
|
||||||
|
call: 'border-white/[0.22] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.38] hover:bg-white/[0.14] [&>svg]:size-[16px] compact:[&>svg]:size-[18px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buttonClass(variant: ButtonVariant, className?: string, size: string = defaultSize) {
|
||||||
|
return cx(base, size, variants[variant], className)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommonProps {
|
||||||
|
variant?: ButtonVariant
|
||||||
|
/** Replaces the default min-height/padding pair. See `defaultSize` above. */
|
||||||
|
size?: string
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: CommonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size'>) {
|
||||||
|
return (
|
||||||
|
<button className={buttonClass(variant, className, size)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonLink({
|
||||||
|
variant = 'primary',
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||||
|
return (
|
||||||
|
<a className={buttonClass(variant, className, size)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { econGain, econLoss, econMetrics, econOps } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { formatMoney, formatNumber, parseAmount } from '../lib/format'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { Container, Section } from './Layout'
|
||||||
|
|
||||||
|
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
|
||||||
|
const badge = 'grid size-[28px] place-items-center rounded-[10px] text-[11px]'
|
||||||
|
|
||||||
|
export function EconomicsSection() {
|
||||||
|
const head = useReveal<HTMLElement>()
|
||||||
|
const metrics = useReveal<HTMLDivElement>()
|
||||||
|
const loss = useReveal<HTMLElement>()
|
||||||
|
const gain = useReveal<HTMLElement>()
|
||||||
|
const bridge = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="hotel-economics" tone="none" className="overflow-hidden bg-econ-bg text-navy">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -top-[270px] -right-[260px] size-[520px] rounded-full bg-teal/[0.13] blur-[18px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<header ref={head.ref} className={cx('relative z-1 mb-[28px] grid max-w-[920px] gap-[14px]', head.revealClass)}>
|
||||||
|
<span className="flex items-center gap-[10px] text-[11px] font-[850] tracking-[0.095em] text-teal-ink uppercase before:h-[2px] before:w-[28px] before:rounded-[3px] before:bg-teal before:content-['']">
|
||||||
|
Экономика текущего гостевого потока
|
||||||
|
</span>
|
||||||
|
<h2 className="mb-0 text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy phone:text-[34px]">
|
||||||
|
Номер уже продан. Следующая выручка — внутри маршрута гостя
|
||||||
|
</h2>
|
||||||
|
<p className="mb-0 max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-muted">
|
||||||
|
Средняя загрузка снижается с 62,0% до 59,1%, а средняя цена проданного номера растёт быстрее выручки на один
|
||||||
|
доступный номер. Это означает, что повышение стоимости проживания не полностью превращается в рост
|
||||||
|
доходности. ЭкзоОтель добавляет отдельный платный сервис к уже существующему гостевому потоку.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={metrics.ref}
|
||||||
|
aria-label="Ключевые показатели экономики отеля"
|
||||||
|
className={cx('relative z-1 mb-[14px] grid grid-cols-2 gap-[10px] sm:grid-cols-4', metrics.revealClass)}
|
||||||
|
>
|
||||||
|
{econMetrics.map((metric) => (
|
||||||
|
<article
|
||||||
|
key={metric.label}
|
||||||
|
className="min-w-0 rounded-[18px] border border-teal/[0.26] bg-white/90 px-[16px] py-[17px] shadow-[0_12px_36px_rgb(4_31_50/0.055)] phone:px-[13px] phone:py-[15px]"
|
||||||
|
>
|
||||||
|
<strong className="block text-[clamp(22px,3vw,34px)] leading-[1.05] tracking-[-0.035em] text-econ-teal-deep tabular-nums">
|
||||||
|
{metric.value}
|
||||||
|
</strong>
|
||||||
|
<span className="mt-[7px] block text-[12px] leading-[1.32] font-[760] text-navy">{metric.label}</span>
|
||||||
|
<small className="mt-[4px] block text-[10px] leading-[1.35] text-muted">{metric.note}</small>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-1 grid gap-[14px] md:grid-cols-2">
|
||||||
|
<article
|
||||||
|
ref={loss.ref}
|
||||||
|
className={cx(
|
||||||
|
'econ-panel-loss min-w-0 rounded-[25px] p-[22px] text-white shadow-[0_24px_60px_rgb(4_25_41/0.16)] md:p-[27px] phone:p-[18px]',
|
||||||
|
loss.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h3 className="mb-[17px] flex items-center gap-[11px] text-[20px] leading-[1.15] tracking-[-0.025em] text-white">
|
||||||
|
<i className="grid size-[34px] place-items-center rounded-[12px] bg-econ-red/[0.16] text-[17px] not-italic text-econ-red-pale">
|
||||||
|
↘
|
||||||
|
</i>
|
||||||
|
Где деньги остаются нереализованными
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-[9px]">
|
||||||
|
{econLoss.map((item) => (
|
||||||
|
<div key={item.num} className={cx(panelItem, 'border border-white/[0.11] bg-white/[0.045]')}>
|
||||||
|
<b className={cx(badge, 'bg-econ-red/[0.16] text-econ-red-pale')}>{item.num}</b>
|
||||||
|
<div>
|
||||||
|
<strong className="mt-px mb-[4px] block text-[14px] leading-[1.25]">{item.title}</strong>
|
||||||
|
<span className="block text-[11px] leading-[1.43] text-white/[0.65]">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
ref={gain.ref}
|
||||||
|
className={cx(
|
||||||
|
'min-w-0 rounded-[25px] border border-teal/[0.34] bg-white/[0.96] p-[22px] shadow-[0_24px_60px_rgb(4_31_50/0.075)] md:p-[27px] phone:p-[18px]',
|
||||||
|
gain.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h3 className="mb-[17px] flex items-center gap-[11px] text-[20px] leading-[1.15] tracking-[-0.025em] text-navy">
|
||||||
|
<i className="grid size-[34px] place-items-center rounded-[12px] bg-econ-ice text-[17px] not-italic text-teal-ink">
|
||||||
|
↗
|
||||||
|
</i>
|
||||||
|
На чём зарабатывает отель с Экзо
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-[9px]">
|
||||||
|
{econGain.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.num}
|
||||||
|
className={cx(panelItem, 'border border-navy/[0.095] bg-linear-to-b from-white to-[#F7FBFB]')}
|
||||||
|
>
|
||||||
|
<b className={cx(badge, 'bg-econ-ice text-teal-ink')}>{item.num}</b>
|
||||||
|
<div>
|
||||||
|
<strong className="mt-px mb-[4px] block text-[14px] leading-[1.25]">{item.title}</strong>
|
||||||
|
<span className="block text-[11px] leading-[1.43] text-muted">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={bridge.ref}
|
||||||
|
className={cx(
|
||||||
|
'relative z-1 my-[14px] grid gap-[10px] rounded-[19px] border border-teal/[0.28] bg-[linear-gradient(115deg,rgb(0_196_180/0.12),rgb(255_255_255/0.94))] px-[18px] py-[16px]',
|
||||||
|
bridge.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<strong className="text-[16px] leading-[1.25] text-navy">
|
||||||
|
Операционная база уже позволяет строить отдельную точку выручки
|
||||||
|
</strong>
|
||||||
|
<div className="grid grid-cols-2 gap-[8px] sm:grid-cols-4">
|
||||||
|
{econOps.map((op) => (
|
||||||
|
<div key={op.value} className="rounded-[14px] border border-teal/[0.22] bg-white px-[12px] py-[11px]">
|
||||||
|
<b className="block text-[17px] leading-[1.05] text-econ-teal-deep">{op.value}</b>
|
||||||
|
<span className="mt-[4px] block text-[9px] leading-[1.25] text-muted">{op.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 text-[11px] leading-[1.5] text-muted">
|
||||||
|
48 аппарат-часов — технический ориентир: 4 аппарата × 12 часов. Фактическая пропускная способность зависит
|
||||||
|
от меню программ, сочетания процедур, санитарных интервалов и участия специалиста.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RevenueCalculator />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `оплаченные программы в день × средний чек × рабочие дни = выручка в месяц` */
|
||||||
|
function RevenueCalculator() {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||||
|
const [programs, setPrograms] = useState('')
|
||||||
|
const [check, setCheck] = useState('')
|
||||||
|
const [days, setDays] = useState('30')
|
||||||
|
|
||||||
|
const programsValue = parseAmount(programs)
|
||||||
|
const checkValue = parseAmount(check)
|
||||||
|
const daysValue = parseAmount(days)
|
||||||
|
const ready = programsValue > 0 && checkValue > 0 && daysValue > 0
|
||||||
|
const volume = programsValue * daysValue
|
||||||
|
const month = volume * checkValue
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cx(
|
||||||
|
'econ-calc-surface relative z-1 mt-[14px] rounded-[25px] p-[22px] text-white shadow-[0_25px_70px_rgb(4_25_41/0.18)] md:p-[28px]',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-[17px] grid gap-[6px]">
|
||||||
|
<span className="text-[10px] font-[850] tracking-[0.09em] text-teal-bright uppercase">Калькулятор сценария</span>
|
||||||
|
<h3 className="mb-0 text-[22px] leading-[1.15] text-white">
|
||||||
|
Посчитайте дополнительную выручку на своих тарифах
|
||||||
|
</h3>
|
||||||
|
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
|
||||||
|
Введите фактическое число оплаченных программ, средний чек и рабочие дни.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-[13px] md:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] md:items-end">
|
||||||
|
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
|
||||||
|
<CalcField
|
||||||
|
id="hotelPrograms"
|
||||||
|
label="Программ в день"
|
||||||
|
value={programs}
|
||||||
|
onChange={setPrograms}
|
||||||
|
placeholder="например, 8"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
<CalcField
|
||||||
|
id="hotelCheck"
|
||||||
|
label="Средний чек, ₽"
|
||||||
|
value={check}
|
||||||
|
onChange={setCheck}
|
||||||
|
placeholder="ваш тариф"
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
/>
|
||||||
|
<CalcField
|
||||||
|
id="hotelDays"
|
||||||
|
label="Рабочих дней в месяц"
|
||||||
|
value={days}
|
||||||
|
onChange={setDays}
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-[9px] md:self-stretch phone:grid-cols-1">
|
||||||
|
<CalcResult
|
||||||
|
label="Дополнительная выручка / месяц"
|
||||||
|
value={ready ? formatMoney(month) : '—'}
|
||||||
|
note={ready ? `${formatNumber(volume)} программ в месяц` : 'Введите программы и чек'}
|
||||||
|
/>
|
||||||
|
<CalcResult
|
||||||
|
label="Дополнительная выручка / год"
|
||||||
|
value={ready ? formatMoney(month * 12) : '—'}
|
||||||
|
note="до вычета расходов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-[13px] mb-0 text-[9px] leading-[1.45] text-white/[0.43]">
|
||||||
|
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
|
||||||
|
рассчитываются после аудита отеля.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalcField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-w-0 gap-[6px]">
|
||||||
|
<label htmlFor={id} className="text-[10px] font-[760] text-white/[0.72]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="h-[48px] w-full min-w-0 rounded-[13px] border border-white/[0.17] bg-white/[0.075] px-[13px] text-[15px] font-extrabold text-white tabular-nums outline-none placeholder:text-white/[0.28] focus:border-teal focus:shadow-[0_0_0_3px_rgb(0_196_180/0.13)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalcResult({ label, value, note }: { label: string; value: string; note: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-[16px] border border-teal/[0.28] bg-teal/[0.09] p-[15px] md:flex md:flex-col md:justify-center">
|
||||||
|
<span className="block text-[9px] tracking-[0.055em] text-white/[0.56] uppercase">{label}</span>
|
||||||
|
<strong className="mt-[6px] block text-[clamp(19px,3vw,29px)] leading-[1.05] tracking-[-0.035em] whitespace-nowrap text-teal-bright tabular-nums phone:whitespace-normal">
|
||||||
|
{value}
|
||||||
|
</strong>
|
||||||
|
<small className="mt-[4px] block text-[9px] leading-[1.3] text-white/[0.48]">{note}</small>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { devices, hotelModel } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { Container, ScrollHint, Section, SectionHead } from './Layout'
|
||||||
|
|
||||||
|
export function EquipmentSection() {
|
||||||
|
const model = useReveal<HTMLDivElement>()
|
||||||
|
const note = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="equipment" tone="none" className="equipment-shell overflow-hidden text-white">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-[-10%] -bottom-[160px] h-[320px] bg-teal/[0.12] blur-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<SectionHead
|
||||||
|
split
|
||||||
|
tone="dark"
|
||||||
|
eyebrow="Что можно купить"
|
||||||
|
title="Комплект оборудования для отеля"
|
||||||
|
lead="Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
|
||||||
|
{devices.map((device, index) => (
|
||||||
|
<DeviceCard key={device.name} {...device} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollHint tone="dark">Листайте оборудование в сторону</ScrollHint>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={model.ref}
|
||||||
|
aria-label="Операционная модель кабинета восстановления в отеле"
|
||||||
|
className={cx(
|
||||||
|
'relative z-1 mt-[26px] rounded-xl border border-white/[0.13] bg-white/[0.06] p-[clamp(22px,3vw,32px)] shadow-[0_24px_70px_rgb(0_0_0/0.14)]',
|
||||||
|
model.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-[19px] grid gap-[9px]">
|
||||||
|
<h3 className="mb-0 text-[clamp(24px,3vw,34px)]">Как работает кабинет внутри отеля</h3>
|
||||||
|
<p className="mb-0 max-w-[820px] text-white/[0.67]">
|
||||||
|
Отель получает не просто четыре аппарата, а рабочую модель: помещение, расписание, меню программ, обучение
|
||||||
|
команды и измеримые показатели пилота.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-[10px] narrow:grid-cols-1">
|
||||||
|
{hotelModel.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.value}
|
||||||
|
className="min-h-[132px] rounded-[18px] border border-white/[0.1] bg-[#031523]/[0.28] p-[17px]"
|
||||||
|
>
|
||||||
|
<strong className="mb-[7px] block text-[clamp(21px,3vw,29px)] leading-none tracking-[-0.03em] text-teal-bright">
|
||||||
|
{card.value}
|
||||||
|
</strong>
|
||||||
|
<span className="block text-[12px] leading-[1.5] text-white/[0.67]">{card.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={note.ref}
|
||||||
|
className={cx(
|
||||||
|
'relative z-1 mt-[22px] rounded-[16px] border border-white/[0.13] bg-white/[0.06] px-[18px] py-[16px] text-[12px] text-white/[0.6]',
|
||||||
|
note.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Гость покупает не название технологии, а понятный сервис: восстановление, лёгкость и премиальный опыт внутри
|
||||||
|
отеля. Медицинские формулировки и протоколы применяются только при наличии лицензии, профильных специалистов и
|
||||||
|
утверждённых протоколов.
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ name, role, duration, tech, text, image, alt, index }: (typeof devices)[number] & { index: number }) {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={ref}
|
||||||
|
style={{ transitionDelay: `${index * 80}ms` }}
|
||||||
|
className={cx(
|
||||||
|
'group relative min-h-[475px] overflow-hidden rounded-[26px] border border-white/[0.13] bg-white/[0.07] shadow-[0_24px_70px_rgb(0_0_0/0.18)] md:min-h-[510px]',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative h-[230px] overflow-hidden after:absolute after:inset-0 after:bg-[linear-gradient(180deg,transparent_40%,rgb(6_27_47/0.86))] after:content-[''] md:h-[245px]">
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
src={image}
|
||||||
|
width={1080}
|
||||||
|
height={720}
|
||||||
|
loading="lazy"
|
||||||
|
className="size-full object-cover transition-transform duration-700 ease-[cubic-bezier(.2,.8,.2,1)] group-hover:scale-[1.045]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-[22px] pt-[2px] pb-[23px]">
|
||||||
|
<div className="relative z-2 -mt-[26px] flex flex-wrap items-center justify-between gap-[12px]">
|
||||||
|
<h3 className="mb-0 text-[27px]">{name}</h3>
|
||||||
|
<div className="flex min-h-[34px] w-[150px] shrink-0 items-center justify-center rounded-[12px] border border-teal/[0.28] bg-teal/[0.1] px-[10px] py-[7px] text-center text-[11px] leading-[1.15] font-[790] text-teal-bright [overflow-wrap:anywhere] compact:w-[142px]">
|
||||||
|
{role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="mt-[15px] mb-[14px] inline-flex rounded-full bg-teal/[0.12] px-[10px] py-[7px] text-[12px] font-[750] text-teal-bright">
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
<p className="mb-[10px] text-[13px] leading-[1.45] font-[760] text-white">{tech}</p>
|
||||||
|
<p className="mb-0 text-[14px] text-white/[0.66]">{text}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
|
||||||
|
/** Glass controls on the navy CTA panel. */
|
||||||
|
const controlClass =
|
||||||
|
'w-full rounded-[14px] border border-white/[0.18] bg-[#041624]/[0.44] text-white outline-none ' +
|
||||||
|
'transition-[border-color,box-shadow,background-color] duration-200 placeholder:text-white/[0.34] ' +
|
||||||
|
'focus:border-teal focus:bg-[#041624]/[0.62] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.13)]'
|
||||||
|
|
||||||
|
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}>
|
||||||
|
<label htmlFor={id} className="text-[12px] font-[680] text-white/[0.74]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
full,
|
||||||
|
...rest
|
||||||
|
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
|
||||||
|
return (
|
||||||
|
<Field id={id} label={label} full={full}>
|
||||||
|
<input id={id} className={cx(controlClass, 'h-[52px] px-[15px]')} {...rest} />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextareaField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
full,
|
||||||
|
...rest
|
||||||
|
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<Field id={id} label={label} full={full}>
|
||||||
|
<textarea id={id} className={cx(controlClass, 'min-h-[105px] resize-y px-[15px] py-[13px]')} {...rest} />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
|
||||||
|
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className="honeypot">
|
||||||
|
<label htmlFor="website">Не заполнять</label>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
id="website"
|
||||||
|
name="website"
|
||||||
|
tabIndex={-1}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { programs, scenarios } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { Container, ScrollHint, Section, SectionHead } from './Layout'
|
||||||
|
|
||||||
|
export function FormatsSection() {
|
||||||
|
const menu = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="formats" tone="white" compact>
|
||||||
|
<Container>
|
||||||
|
<SectionHead
|
||||||
|
split
|
||||||
|
eyebrow="Сценарии размещения"
|
||||||
|
title="Встраивается в маршрут гостя"
|
||||||
|
lead="Формат подстраивается под инфраструктуру отеля: отдельный SPA-кабинет, заметная капсула рядом с фитнесом, зона отдыха у бассейна или персональный сервис в номере."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scroll-cards gap-[16px] md:grid-cols-4">
|
||||||
|
{scenarios.map((scenario, order) => (
|
||||||
|
<ScenarioCard key={scenario.title} {...scenario} order={order} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollHint>Листайте сценарии в сторону</ScrollHint>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={menu.ref}
|
||||||
|
aria-label="Пример меню программ для гостей отеля"
|
||||||
|
className={cx(
|
||||||
|
'mt-[clamp(34px,5vw,54px)] rounded-xl border border-navy/[0.12] bg-[linear-gradient(145deg,#F7FCFC,#FFFFFF)] p-[clamp(22px,3vw,32px)] shadow-[0_18px_60px_rgb(10_37_64/0.06)]',
|
||||||
|
menu.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-[20px] grid gap-[9px]">
|
||||||
|
<h3 className="mb-0 text-[clamp(25px,3vw,36px)]">Продавать не аппарат, а понятную программу гостю</h3>
|
||||||
|
<p className="mb-0 max-w-[800px] text-muted">
|
||||||
|
Названия меню говорят языком сценария поездки: дорога, ноги, спина, активный день, SPA и время для себя.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scroll-cards gap-[12px] md:grid-cols-3">
|
||||||
|
{programs.map((program) => (
|
||||||
|
<article
|
||||||
|
key={program.title}
|
||||||
|
className="min-h-[220px] rounded-[19px] border border-teal/[0.23] bg-white p-[19px]"
|
||||||
|
>
|
||||||
|
<span className="mb-[14px] inline-flex rounded-full bg-teal/[0.11] px-[9px] py-[6px] text-[11px] font-[780] text-[#007E75]">
|
||||||
|
{program.time}
|
||||||
|
</span>
|
||||||
|
<h4 className="mb-[7px] text-[17px] leading-[1.2] text-navy">{program.title}</h4>
|
||||||
|
|
||||||
|
<div className="mt-[12px] grid gap-[10px]">
|
||||||
|
{program.devices.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.name}
|
||||||
|
className="grid gap-[3px] border-t border-navy/[0.09] pt-[10px] first:border-t-0 first:pt-0"
|
||||||
|
>
|
||||||
|
<strong className="text-[13px] font-extrabold text-navy">{device.name}</strong>
|
||||||
|
<span className="text-[12px] leading-[1.5] text-muted">{device.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollHint>Листайте программы в сторону</ScrollHint>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScenarioCard({ index: label, title, text, image, alt, order }: (typeof scenarios)[number] & { order: number }) {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={ref}
|
||||||
|
style={{ transitionDelay: `${order * 80}ms` }}
|
||||||
|
className={cx(
|
||||||
|
'group relative isolate min-h-[330px] overflow-hidden rounded-lg text-white shadow-[0_20px_60px_rgb(10_37_64/0.14)]',
|
||||||
|
"before:absolute before:inset-0 before:-z-2 before:bg-[linear-gradient(180deg,rgb(6_27_47/0.04)_25%,rgb(6_27_47/0.9)_100%)] before:content-['']",
|
||||||
|
"after:absolute after:inset-x-0 after:bottom-0 after:-z-1 after:h-[4px] after:origin-left after:scale-x-[0.22] after:bg-teal after:transition-transform after:duration-450 after:content-[''] group-hover:after:scale-x-100",
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
src={image}
|
||||||
|
width={960}
|
||||||
|
height={640}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 -z-3 size-full object-cover transition-transform duration-700 group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-[24px]">
|
||||||
|
<span className="mb-[9px] block text-[12px] font-extrabold tracking-[0.12em] text-teal-bright">{label}</span>
|
||||||
|
<h3 className="mb-[8px]">{title}</h3>
|
||||||
|
<p className="mb-0 text-[13px] text-white/[0.7]">{text}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { guestPortraits } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { guestIcons } from './Icons'
|
||||||
|
import { Container, ScrollHint, Section, SectionHead } from './Layout'
|
||||||
|
|
||||||
|
export function GuestsSection() {
|
||||||
|
return (
|
||||||
|
<Section id="guests" tone="white" compact>
|
||||||
|
<Container>
|
||||||
|
<SectionHead
|
||||||
|
eyebrow="Потребность гостя"
|
||||||
|
title="Портрет гостя отеля"
|
||||||
|
lead="Кабинет востребован у гостей, которые хотят восстановиться, не меняя привычный маршрут отдыха или поездки."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scroll-cards gap-[16px] md:grid-cols-4">
|
||||||
|
{guestPortraits.map((portrait, index) => (
|
||||||
|
<PortraitCard key={portrait.title} {...portrait} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollHint>Листайте карточки в сторону</ScrollHint>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortraitCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
index,
|
||||||
|
}: (typeof guestPortraits)[number] & { index: number }) {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={ref}
|
||||||
|
style={{ transitionDelay: `${index * 80}ms` }}
|
||||||
|
className={cx(
|
||||||
|
'rounded-lg border border-teal/[0.28] bg-[linear-gradient(145deg,#FFFFFF,#F0FBFA)] p-[24px]',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-[26px] grid size-[48px] place-items-center rounded-[15px] bg-teal/[0.14] text-navy [&>svg]:size-[24px]">
|
||||||
|
{guestIcons[icon]}
|
||||||
|
</div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="mb-0 text-[14px] text-muted">{text}</p>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import heroBg from '../assets/images/hero-bg.webp'
|
||||||
|
import { heroFacts } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { ButtonLink } from './Button'
|
||||||
|
import { ArrowRightIcon } from './Icons'
|
||||||
|
import { Container } from './Layout'
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
const kicker = useReveal<HTMLDivElement>()
|
||||||
|
const title = useReveal<HTMLHeadingElement>()
|
||||||
|
const subtitle = useReveal<HTMLParagraphElement>()
|
||||||
|
const actions = useReveal<HTMLDivElement>()
|
||||||
|
const facts = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="top"
|
||||||
|
aria-labelledby="hero-title"
|
||||||
|
className="relative isolate flex min-h-[760px] items-end overflow-hidden bg-navy-deep pt-[122px] pb-[48px] text-white
|
||||||
|
sm:min-h-[820px] sm:pb-[60px] md:min-h-[790px] md:items-center md:pt-[150px] md:pb-[78px]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 -z-3 scale-[1.02] bg-cover bg-[position:60%_center]"
|
||||||
|
style={{ backgroundImage: `url(${heroBg})` }}
|
||||||
|
/>
|
||||||
|
<div aria-hidden="true" className="hero-scrim absolute inset-0 -z-2" />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -right-[220px] -bottom-[180px] -z-1 size-[480px] rounded-full bg-teal/[0.38] blur-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<div className="w-[min(100%,950px)]">
|
||||||
|
<div
|
||||||
|
ref={kicker.ref}
|
||||||
|
className={cx(
|
||||||
|
'mb-[22px] inline-flex items-center gap-[10px] rounded-full border border-white/[0.22] bg-[#051E2F]/[0.36] px-[13px] py-[9px]',
|
||||||
|
'text-[12px] font-[780] tracking-[0.1em] text-white/[0.84] uppercase backdrop-blur-[14px]',
|
||||||
|
kicker.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<i aria-hidden="true" className="size-[8px] rounded-full bg-teal shadow-[0_0_0_6px_rgb(0_196_180/0.14)]" />
|
||||||
|
Готовый формат внутри отеля
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 ref={title.ref} id="hero-title" className={cx('delay-[80ms]', title.revealClass)}>
|
||||||
|
ЭкзоОтель: Пространство восстановления для гостей
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
ref={subtitle.ref}
|
||||||
|
className={cx(
|
||||||
|
'mb-[30px] max-w-[730px] text-[clamp(19px,2.2vw,27px)] leading-[1.42] text-white/[0.82] delay-[160ms]',
|
||||||
|
subtitle.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Кабинет восстановления, расслабления и дополнительной выручки внутри отеля
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div ref={actions.ref} className={cx('mb-[34px] flex flex-wrap gap-[12px] delay-[160ms]', actions.revealClass)}>
|
||||||
|
<ButtonLink href="#proposal">
|
||||||
|
Получить предложение
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</ButtonLink>
|
||||||
|
<ButtonLink href="#equipment" variant="secondary">
|
||||||
|
Подобрать оборудование
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={facts.ref}
|
||||||
|
aria-label="Ключевые параметры формата"
|
||||||
|
className={cx(
|
||||||
|
'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1',
|
||||||
|
facts.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{heroFacts.map((fact) => (
|
||||||
|
// Under 430px the fact turns into a value/label row. The original used an
|
||||||
|
// 80px column, which the widest value overflowed into the label; 124px
|
||||||
|
// keeps every value on one line.
|
||||||
|
<div
|
||||||
|
key={fact.value}
|
||||||
|
className="min-h-[82px] rounded-[18px] border border-white/[0.16] bg-[#051D2F]/[0.42] px-[16px] py-[15px] backdrop-blur-[16px]
|
||||||
|
tiny:grid tiny:min-h-0 tiny:grid-cols-[124px_1fr] tiny:items-center tiny:gap-[10px]"
|
||||||
|
>
|
||||||
|
<strong className="mb-[3px] block text-[clamp(20px,3vw,29px)] leading-[1.1] tracking-[-0.03em] tiny:mb-0 tiny:text-[21px]">
|
||||||
|
{fact.value}
|
||||||
|
</strong>
|
||||||
|
<span className="text-[12px] text-white/[0.62]">{fact.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
|
||||||
|
function Icon({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArrowRightIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhoneIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.35 1.78.68 2.62a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.46-1.25a2 2 0 0 1 2.11-.45c.84.33 1.72.56 2.62.68A2 2 0 0 1 22 16.92Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
/>
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronsIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M8 7l-5 5 5 5M16 7l5 5-5 5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icons of the guest portraits, keyed by `guestPortraits[].icon`. */
|
||||||
|
export const guestIcons = {
|
||||||
|
road: (
|
||||||
|
<Icon>
|
||||||
|
<path d="M3 12h18M5 8h4l2-4h2l2 4h4l2 4-2 4h-4l-2 4h-2l-2-4H5l-2-4 2-4Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
active: (
|
||||||
|
<Icon>
|
||||||
|
<path
|
||||||
|
d="M13 5.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM10 22l1-6-3-3 2-5 4 1 2 3h4M5 22l2-6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
wellness: (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 21c4.5-3 7-6.2 7-10a7 7 0 0 0-14 0c0 3.8 2.5 7 7 10Z" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
<path
|
||||||
|
d="M9 11c1.8.1 3.2-.9 4-3 1.8 1.4 2.4 3.2 1.5 5.3-1 2.2-3.3 3.1-5.5 2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
business: (
|
||||||
|
<Icon>
|
||||||
|
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 9h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
<path d="M3 13h18M10 13v2h4v-2" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icons of the "почему Экзо" list, keyed by `whyItems[].icon`. */
|
||||||
|
export const whyIcons = {
|
||||||
|
tech: (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 3 3 7.5 12 12l9-4.5L12 3Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
|
||||||
|
<path d="M5 11v5.5L12 21l7-4.5V11" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
factory: (
|
||||||
|
<Icon>
|
||||||
|
<path d="M3 21V9l5 3V7l5 3V3h8v18H3Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
|
||||||
|
<path d="M16 7h2M16 11h2M7 16h2M12 16h2M17 16h2" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
launch: (
|
||||||
|
<Icon>
|
||||||
|
<path d="M4 4h16v16H4V4Z" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
<path d="m8 12 2.5 2.5L16 9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
|
||||||
|
</Icon>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { ChevronsIcon } from './Icons'
|
||||||
|
|
||||||
|
/** `.container` — 1200px max, 16px gutters that grow to 24px from 640px up. */
|
||||||
|
export function Container({ className, children }: { className?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'mx-auto w-[min(calc(100%_-_32px),var(--container-page))] sm:w-[min(calc(100%_-_48px),var(--container-page))]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small uppercase kicker with the leading teal rule. */
|
||||||
|
export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'mb-[16px] inline-flex items-center gap-[10px] text-[12px] font-extrabold tracking-[0.14em] uppercase',
|
||||||
|
"before:h-[2px] before:w-[26px] before:rounded-[2px] before:bg-teal before:content-['']",
|
||||||
|
tone === 'dark' ? 'text-teal-bright' : 'text-teal-ink',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tone = 'light' | 'dark'
|
||||||
|
|
||||||
|
export function SectionLead({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cx(
|
||||||
|
'mb-0 max-w-[760px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
|
||||||
|
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `.section-head` — stacked by default; `split` moves the lead into a second
|
||||||
|
* column from 900px up, right-aligned against the heading.
|
||||||
|
*/
|
||||||
|
export function SectionHead({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
lead,
|
||||||
|
tone = 'light',
|
||||||
|
split = false,
|
||||||
|
}: {
|
||||||
|
eyebrow: string
|
||||||
|
title: ReactNode
|
||||||
|
lead?: ReactNode
|
||||||
|
tone?: Tone
|
||||||
|
split?: boolean
|
||||||
|
}) {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
if (!split) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
|
||||||
|
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cx(
|
||||||
|
'mb-[clamp(34px,5vw,54px)] grid gap-[18px] md:grid-cols-[1.02fr_0.78fr] md:items-end',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
||||||
|
<h2 className="mb-0">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{lead ? (
|
||||||
|
<SectionLead tone={tone} className="md:justify-self-end">
|
||||||
|
{lead}
|
||||||
|
</SectionLead>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionTones = {
|
||||||
|
light: 'bg-paper',
|
||||||
|
white: 'bg-white',
|
||||||
|
dark: 'bg-navy text-white',
|
||||||
|
/** For sections that paint their own multi-layer background in index.css. */
|
||||||
|
none: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `.section` — the shared vertical rhythm and background variants. */
|
||||||
|
export function Section({
|
||||||
|
id,
|
||||||
|
tone = 'light',
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id?: string
|
||||||
|
tone?: keyof typeof sectionTones
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className={cx('relative', compact ? 'py-section-compact' : 'py-section', sectionTones[tone], className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Листайте карточки в сторону" — only shown while the row still scrolls. */
|
||||||
|
export function ScrollHint({ tone = 'light', children }: { tone?: Tone; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx(
|
||||||
|
'mt-[8px] flex items-center gap-[7px] text-[11px] md:hidden [&>svg]:size-[16px]',
|
||||||
|
tone === 'dark' ? 'text-white/[0.52]' : 'text-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronsIcon />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { submitLead } from '../lib/lead'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { Button } from './Button'
|
||||||
|
import { Honeypot, TextField, TextareaField } from './FormField'
|
||||||
|
import { ArrowRightIcon } from './Icons'
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
name: '',
|
||||||
|
company: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
comment: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
|
||||||
|
|
||||||
|
export function LeadForm() {
|
||||||
|
const { ref: formRef, revealClass } = useReveal<HTMLFormElement>()
|
||||||
|
const [values, setValues] = useState(EMPTY)
|
||||||
|
const [honeypot, setHoneypot] = useState('')
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
|
||||||
|
|
||||||
|
const set = (key: keyof typeof EMPTY) => (event: { target: { value: string } }) =>
|
||||||
|
setValues((current) => ({ ...current, [key]: event.target.value }))
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = formRef.current
|
||||||
|
if (!form) return
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity()
|
||||||
|
setStatus({ text: 'Проверьте обязательные поля.', tone: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (honeypot) return
|
||||||
|
|
||||||
|
setPending(true)
|
||||||
|
setStatus({ text: '', tone: 'idle' })
|
||||||
|
|
||||||
|
const result = await submitLead('request', { ...values, website: honeypot })
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setValues(EMPTY)
|
||||||
|
setStatus({
|
||||||
|
text: 'Спасибо. Заявка отправлена — специалист подготовит предложение и свяжется с вами.',
|
||||||
|
tone: 'success',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
id="leadForm"
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className={cx(
|
||||||
|
'rounded-xl border border-white/[0.15] bg-white/[0.09] p-[clamp(22px,4vw,34px)] shadow-[0_32px_90px_rgb(0_0_0/0.2)] backdrop-blur-[18px] delay-[80ms]',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-[14px] sm:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
id="name"
|
||||||
|
label="Имя"
|
||||||
|
name="name"
|
||||||
|
autoComplete="name"
|
||||||
|
minLength={2}
|
||||||
|
placeholder="Как к вам обращаться"
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
onChange={set('name')}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="company"
|
||||||
|
label="Компания / отель"
|
||||||
|
name="company"
|
||||||
|
autoComplete="organization"
|
||||||
|
minLength={2}
|
||||||
|
placeholder="Название отеля"
|
||||||
|
required
|
||||||
|
value={values.company}
|
||||||
|
onChange={set('company')}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="phone"
|
||||||
|
label="Телефон"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
placeholder="+7 900 000-00-00"
|
||||||
|
required
|
||||||
|
value={values.phone}
|
||||||
|
onChange={set('phone')}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="name@hotel.ru"
|
||||||
|
required
|
||||||
|
value={values.email}
|
||||||
|
onChange={set('email')}
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
id="comment"
|
||||||
|
label="Комментарий"
|
||||||
|
name="comment"
|
||||||
|
full
|
||||||
|
placeholder="Город, категория отеля, наличие SPA / фитнес-зоны"
|
||||||
|
value={values.comment}
|
||||||
|
onChange={set('comment')}
|
||||||
|
/>
|
||||||
|
<Honeypot value={honeypot} onChange={setHoneypot} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-[12px] mb-[16px] text-[11px] text-white/[0.48]">
|
||||||
|
Нажимая кнопку, вы подтверждаете согласие на обработку данных для подготовки предложения.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full border-0" disabled={pending} aria-busy={pending}>
|
||||||
|
<span>{pending ? 'Отправляем…' : 'Получить предложение'}</span>
|
||||||
|
{pending ? null : <ArrowRightIcon />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Kept mounted so screen readers announce the result in place. */}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cx(
|
||||||
|
status.text && 'mt-[14px] rounded-[12px] border px-[14px] py-[12px] text-[13px]',
|
||||||
|
status.tone === 'success' && 'border-teal/[0.25] bg-teal/[0.14] text-[#BFFBF4]',
|
||||||
|
status.tone === 'error' && 'border-[#FF5F5F]/[0.24] bg-[#FF5F5F]/[0.12] text-[#FFD5D5]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.text}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { Container, Eyebrow, Section, SectionLead } from './Layout'
|
||||||
|
import { LeadForm } from './LeadForm'
|
||||||
|
|
||||||
|
export function RequestSection() {
|
||||||
|
const copy = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="proposal" tone="none" className="cta-surface overflow-hidden text-white">
|
||||||
|
<div aria-hidden="true" className="cta-grid-overlay absolute inset-0 opacity-[0.23]" />
|
||||||
|
|
||||||
|
<Container className="relative grid items-start gap-[34px] md:grid-cols-[0.95fr_1.05fr] md:gap-[clamp(44px,6vw,88px)]">
|
||||||
|
<div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}>
|
||||||
|
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
||||||
|
<h2 className="max-w-[660px]">Подберём оборудование под формат вашего отеля</h2>
|
||||||
|
<SectionLead tone="dark" className="max-w-[620px]">
|
||||||
|
Оставьте контакты — специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом
|
||||||
|
окупаемости и меню программ.
|
||||||
|
</SectionLead>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeadForm />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import logo from '../assets/images/exo-logo.png'
|
||||||
|
import { Container } from './Layout'
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-footer py-[26px] text-[12px] text-white/[0.5]">
|
||||||
|
<Container className="flex flex-col items-start gap-[17px] sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<img
|
||||||
|
alt="Экзо Групп — российские технологии реабилитации"
|
||||||
|
className="block h-auto w-[clamp(150px,16vw,180px)] max-w-[min(50vw,180px)] object-contain"
|
||||||
|
src={logo}
|
||||||
|
width={900}
|
||||||
|
height={204}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-x-[20px] gap-y-[14px]">
|
||||||
|
<span>ЭкзоОтель · пространство восстановления для гостей</span>
|
||||||
|
<span>© {new Date().getFullYear()} ООО «Экзо Групп»</span>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { RefObject } from 'react'
|
||||||
|
import logo from '../assets/images/exo-logo.png'
|
||||||
|
import { contacts, navLinks } from '../data/content'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { ButtonLink, buttonClass, headerSize } from './Button'
|
||||||
|
import { PhoneIcon } from './Icons'
|
||||||
|
import { Container } from './Layout'
|
||||||
|
|
||||||
|
export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerRef: RefObject<HTMLElement | null> }) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
ref={headerRef}
|
||||||
|
className={cx(
|
||||||
|
'fixed inset-x-0 top-0 z-50 py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250 md:py-[18px]',
|
||||||
|
scrolled && 'bg-navy-deep/[0.86] shadow-[0_12px_40px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Container className="flex items-center justify-between gap-[18px]">
|
||||||
|
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — на первый экран">
|
||||||
|
<img
|
||||||
|
alt="Экзо Групп — российские технологии реабилитации"
|
||||||
|
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left compact:w-[150px]"
|
||||||
|
src={logo}
|
||||||
|
width={900}
|
||||||
|
height={204}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Основная навигация"
|
||||||
|
className="hidden items-center gap-[30px] text-[14px] font-[650] text-white/[0.76] md:flex"
|
||||||
|
>
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="transition-colors duration-200 hover:text-white">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
|
||||||
|
<a
|
||||||
|
href={contacts.phoneHref}
|
||||||
|
aria-label="Позвонить в Экзо Групп"
|
||||||
|
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
|
||||||
|
>
|
||||||
|
<PhoneIcon />
|
||||||
|
<span className="compact:hidden">Звонок</span>
|
||||||
|
</a>
|
||||||
|
<ButtonLink href="#proposal" size={headerSize}>
|
||||||
|
Получить предложение
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import whyVisual from '../assets/images/scenario-recovery.webp'
|
||||||
|
import { whyItems } from '../data/content'
|
||||||
|
import { useReveal } from '../hooks/useReveal'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { whyIcons } from './Icons'
|
||||||
|
import { Container, Section, SectionHead } from './Layout'
|
||||||
|
|
||||||
|
export function WhyExoSection() {
|
||||||
|
const visual = useReveal<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section id="why-exo">
|
||||||
|
<Container>
|
||||||
|
<SectionHead
|
||||||
|
eyebrow="Партнёр внедрения"
|
||||||
|
title="Почему Экзо Групп"
|
||||||
|
lead="Отель получает четыре аппарата, визуальную концепцию, меню программ, расчёт экономики, формат с одним специалистом, обучение, чек-листы запуска и сценарии для SPA и ресепшен."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid items-stretch gap-[30px] md:grid-cols-[1.08fr_0.92fr]">
|
||||||
|
<div
|
||||||
|
ref={visual.ref}
|
||||||
|
className={cx(
|
||||||
|
'relative isolate min-h-[360px] overflow-hidden rounded-xl bg-navy shadow-deep',
|
||||||
|
"before:absolute before:inset-0 before:-z-1 before:bg-[linear-gradient(155deg,rgb(6_27_47/0.18),rgb(6_27_47/0.88))] before:content-['']",
|
||||||
|
visual.revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Пространство восстановления ЭкзоОтель рядом с фитнес-зоной"
|
||||||
|
src={whyVisual}
|
||||||
|
width={960}
|
||||||
|
height={640}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 -z-2 size-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-[24px] bottom-[24px] max-w-[410px] rounded-[20px] border border-white/[0.16] bg-[#061B2F]/[0.62] p-[22px] text-white backdrop-blur-[14px]">
|
||||||
|
<strong className="mb-[6px] block text-[22px] leading-[1.2]">
|
||||||
|
Не просто оборудование — готовое направление внутри отеля
|
||||||
|
</strong>
|
||||||
|
<span className="text-[13px] text-white/[0.66]">
|
||||||
|
Визуальная концепция, меню программ, формат с одним специалистом, обучение, чек-листы и сценарии для SPA
|
||||||
|
и ресепшен.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid content-start gap-[12px]">
|
||||||
|
{whyItems.map((item, index) => (
|
||||||
|
<WhyItem key={item.title} {...item} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WhyItem({ icon, title, text, index }: (typeof whyItems)[number] & { index: number }) {
|
||||||
|
const { ref, revealClass } = useReveal<HTMLElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={ref}
|
||||||
|
style={{ transitionDelay: `${index * 80}ms` }}
|
||||||
|
className={cx(
|
||||||
|
'grid grid-cols-[48px_1fr] items-start gap-[15px] rounded-[18px] border border-navy/[0.12] bg-white p-[18px]',
|
||||||
|
revealClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid size-[48px] place-items-center rounded-[15px] bg-ice text-navy [&>svg]:size-[23px]">
|
||||||
|
{whyIcons[icon]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong className="mb-[4px] block text-[16px]">{title}</strong>
|
||||||
|
<p className="mb-0 text-[13px] text-muted">{text}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import deviceHydrogen from '../assets/images/device-hydrogen.webp'
|
||||||
|
import devicePress from '../assets/images/device-press.webp'
|
||||||
|
import deviceTecar from '../assets/images/device-tecar.webp'
|
||||||
|
import deviceTherapy from '../assets/images/device-therapy.webp'
|
||||||
|
import scenarioActive from '../assets/images/scenario-active.webp'
|
||||||
|
import scenarioRecovery from '../assets/images/scenario-recovery.webp'
|
||||||
|
import scenarioSpa from '../assets/images/scenario-spa.webp'
|
||||||
|
import scenarioWellness from '../assets/images/scenario-wellness.webp'
|
||||||
|
|
||||||
|
export const contacts = {
|
||||||
|
phone: '+7 939 717-80-80',
|
||||||
|
phoneHref: 'tel:+79397178080',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navLinks = [
|
||||||
|
{ href: '#benefits', label: 'Выгода' },
|
||||||
|
{ href: '#guests', label: 'Гости' },
|
||||||
|
{ href: '#equipment', label: 'Оборудование' },
|
||||||
|
{ href: '#formats', label: 'Форматы' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const heroFacts = [
|
||||||
|
{ value: '4 аппарата', label: 'единое пространство восстановления' },
|
||||||
|
{ value: '1 оператор', label: 'при дисциплинированном расписании' },
|
||||||
|
{ value: '12 часов', label: 'базовый режим работы кабинета' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Выгода для отеля */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const benefits = [
|
||||||
|
{
|
||||||
|
num: '01',
|
||||||
|
title: 'Новый источник выручки на текущем потоке гостей',
|
||||||
|
text: 'Дополнительная точка продаж внутри существующей инфраструктуры отеля.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '02',
|
||||||
|
title: 'Премиальный сервис без расширения номерного фонда',
|
||||||
|
text: 'Современная зона дополняет SPA, wellness или фитнес-пространство.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '03',
|
||||||
|
title: 'Причина остаться в SPA / фитнес-зоне дольше',
|
||||||
|
text: 'Готовое меню программ и дополнительные услуги к проживанию.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '04',
|
||||||
|
title: 'Стимул для LTV и привлечения новых гостей',
|
||||||
|
text: 'Заметный wellness-tech сервис создаёт дополнительный повод выбрать отель, купить больше услуг во время проживания и вернуться за знакомым форматом отдыха.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const marketImplications = [
|
||||||
|
{
|
||||||
|
title: 'LTV текущего гостя',
|
||||||
|
text: 'Больше поводов купить SPA / wellness-услугу, включить восстановление в пакет проживания и вернуться за повторным опытом.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Новые гости',
|
||||||
|
text: 'Выразительное пространство восстановления усиливает предложение отеля и становится дополнительным аргументом при выборе поездки.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Текущий поток',
|
||||||
|
text: 'Дополнительная точка выручки внутри действующей инфраструктуры — без расширения номерного фонда.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const glossary = [
|
||||||
|
{ term: 'RevPAR', text: 'Revenue per Available Room — выручка на один доступный номер.' },
|
||||||
|
{ term: 'ADR', text: 'Average Daily Rate — средняя цена проданного номера.' },
|
||||||
|
{ term: 'LTV', text: 'Lifetime Value — совокупная ценность гостя за весь период отношений с отелем.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Экономика */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const econMetrics = [
|
||||||
|
{ value: '−2,9', label: 'процентного пункта снижения загрузки', note: 'с 62,0% до 59,1% в прогнозе на 2026 год' },
|
||||||
|
{
|
||||||
|
value: '2,7',
|
||||||
|
label: 'процентного пункта — разрыв темпов роста',
|
||||||
|
note: 'цена проданного номера +5,2%; выручка на доступный номер +2,5%',
|
||||||
|
},
|
||||||
|
{ value: '+14%', label: 'рост предложения', note: 'конкуренция за гостя усиливается' },
|
||||||
|
{ value: '92 млн', label: 'гостевой поток 2025', note: 'спрос есть — важна глубина монетизации' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const econLoss = [
|
||||||
|
{
|
||||||
|
num: '01',
|
||||||
|
title: 'Доход зависит в основном от номера',
|
||||||
|
text: 'При снижении загрузки пустой номер уже нельзя компенсировать одним повышением средней цены проданного номера.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '02',
|
||||||
|
title: 'Потребность возникает, но не продаётся',
|
||||||
|
text: 'Усталость после дороги, лыж, прогулок или тренировок появляется уже в отеле — без recovery-меню чек не возникает.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '03',
|
||||||
|
title: 'Дополнительный расход уходит наружу',
|
||||||
|
text: 'Гость покупает массаж и восстановление вне отеля либо вовсе отказывается от услуги.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const econGain = [
|
||||||
|
{
|
||||||
|
num: '01',
|
||||||
|
title: 'Разовые программы в момент спроса',
|
||||||
|
text: 'Гость покупает понятный сценарий: после дороги, для ног, спины, SPA или активного дня.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '02',
|
||||||
|
title: 'Пакеты и upgrade к проживанию',
|
||||||
|
text: 'Recovery добавляется к SPA, фитнесу, premium-тарифу номера или программе активного отдыха.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '03',
|
||||||
|
title: 'Повторный опыт и рост LTV',
|
||||||
|
text: 'Знакомый wellness-ритуал становится причиной купить больше услуг и вернуться в отель.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const econOps = [
|
||||||
|
{ value: '4 аппарата', label: 'разные сценарии гостя' },
|
||||||
|
{ value: '1 оператор', label: 'основное участие — ЭкзоТекар' },
|
||||||
|
{ value: '12 часов', label: 'базовый режим расписания' },
|
||||||
|
{ value: 'до 48', label: 'аппарат-часов в день до учёта буферов' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Гости */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const guestPortraits = [
|
||||||
|
{
|
||||||
|
icon: 'road',
|
||||||
|
title: 'Гость после дороги',
|
||||||
|
text: 'После перелёта или длительной поездки хочет снять усталость, напряжение в теле и быстрее включиться в отдых.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'active',
|
||||||
|
title: 'Активный гость',
|
||||||
|
text: 'Выбирает лыжи, прогулки, экскурсии, бассейн, тренировки или хайкинг и восстанавливается между активностями.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'wellness',
|
||||||
|
title: 'Wellness-гость',
|
||||||
|
text: 'Посещает SPA, массаж, бассейн или банный комплекс и воспринимает кабинет как дополнительный сервис восстановления.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'business',
|
||||||
|
title: 'Деловой гость',
|
||||||
|
text: 'Ценит возможность уделить время себе, не выезжая из отеля и не перестраивая график встреч или командировки.',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Оборудование */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const devices = [
|
||||||
|
{
|
||||||
|
name: 'ЭкзоТерапия',
|
||||||
|
role: 'Флагман SPA',
|
||||||
|
duration: '10 минут',
|
||||||
|
tech: 'Высокоинтенсивная импульсная магнитотерапия.',
|
||||||
|
text: 'Короткий 10-минутный ритуал для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
|
||||||
|
image: deviceTherapy,
|
||||||
|
alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ЭкзоТекар',
|
||||||
|
role: 'Работа специалиста',
|
||||||
|
duration: '20–30 минут',
|
||||||
|
tech: 'Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.',
|
||||||
|
text: 'Индивидуальная работа специалиста после активного дня — для шеи, спины, плеч или ног. Процедура занимает отдельный слот в расписании.',
|
||||||
|
image: deviceTecar,
|
||||||
|
alt: 'ЭкзоТекар в кабинете премиальных процедур',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ЭкзоПресс',
|
||||||
|
role: 'Потоковый формат',
|
||||||
|
duration: '20–30 минут',
|
||||||
|
tech: 'Компрессионная терапия + тепловая терапия + холодовая терапия.',
|
||||||
|
text: 'Компрессия + тепло / холод для ног после дороги, перелёта, экскурсий и лыж. Потоковый формат, который не требует отдельного кабинета.',
|
||||||
|
image: devicePress,
|
||||||
|
alt: 'ЭкзоПресс у бассейна в SPA-зоне',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ЭкзоВодород',
|
||||||
|
role: 'Автономная пауза',
|
||||||
|
duration: '40 минут',
|
||||||
|
tech: 'Ингаляция молекулярным водородом',
|
||||||
|
text: 'Комфортная 40-минутная пауза в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
|
||||||
|
image: deviceHydrogen,
|
||||||
|
alt: 'ЭкзоВодород в спокойной wellness-зоне',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const hotelModel = [
|
||||||
|
{ value: '12–45 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
|
||||||
|
{
|
||||||
|
value: '1 оператор',
|
||||||
|
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
|
||||||
|
},
|
||||||
|
{ value: '12 часов', text: 'Расписание с буферами на подготовку места, сопровождение гостя и санитарную обработку.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Форматы размещения и меню программ */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const scenarios = [
|
||||||
|
{
|
||||||
|
index: '01 · SPA',
|
||||||
|
title: 'SPA-зона',
|
||||||
|
text: 'Формат 3-в-1 или приватный кабинет, где гость проходит несколько процедур подряд по записи.',
|
||||||
|
image: scenarioSpa,
|
||||||
|
alt: 'Приватный SPA-кабинет с оборудованием Экзо Групп',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: '02 · Wellness',
|
||||||
|
title: 'Wellness-зона',
|
||||||
|
text: 'ЭкзоВодород или ЭкзоПресс в комнате отдыха, у бассейна, перед SPA или как персональный сервис в номер.',
|
||||||
|
image: scenarioWellness,
|
||||||
|
alt: 'ЭкзоВодород в wellness-зоне отдыха',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: '03 · Recovery',
|
||||||
|
title: 'Recovery-зона',
|
||||||
|
text: 'Мягкая или стеклянная капсула рядом с фитнесом и SPA: технология заметна гостю, но процедура сохраняет приватность.',
|
||||||
|
image: scenarioRecovery,
|
||||||
|
alt: 'Стеклянная recovery-зона рядом с фитнесом',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: '04 · Active day',
|
||||||
|
title: 'После активного дня',
|
||||||
|
text: 'Готовые программы после лыж, хайкинга, прогулок, бассейна, тренировки или экскурсии.',
|
||||||
|
image: scenarioActive,
|
||||||
|
alt: 'ЭкзоТерапия рядом с фитнес-зоной',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const programs = [
|
||||||
|
{
|
||||||
|
time: '40–60 минут',
|
||||||
|
title: 'Восстановление после дороги',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
name: 'ЭкзоВодород',
|
||||||
|
text: '40-минутная автономная пауза в wellness-зоне после перелёта или длительной поездки.',
|
||||||
|
},
|
||||||
|
{ name: 'ЭкзоПресс', text: 'Пассивный этап для ног после длительного сидения, дороги и перелёта.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '20–30 минут',
|
||||||
|
title: 'Лёгкость в ногах',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
name: 'ЭкзоПресс',
|
||||||
|
text: 'Компрессионный, тепловой или холодовой формат после прогулок, экскурсий, лыж и долгого дня на ногах.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ЭкзоТекар',
|
||||||
|
text: 'Помогает снять мышечное напряжение и ощущение тяжести, улучшить локальное восстановление мягких тканей после длительной ходьбы, активного дня или нагрузки на ноги.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '45–70 минут',
|
||||||
|
title: 'После активного дня',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
name: 'ЭкзоТекар',
|
||||||
|
text: 'Индивидуальная работа специалиста с локальными зонами после лыж, хайкинга, тренировки или бассейна.',
|
||||||
|
},
|
||||||
|
{ name: 'ЭкзоПресс', text: 'Пассивный этап для ног после продолжительной физической нагрузки.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '35–50 минут',
|
||||||
|
title: 'Спина и шея',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
name: 'ЭкзоТекар',
|
||||||
|
text: 'Индивидуальная работа специалиста с областью спины, шеи и плеч по предварительной записи.',
|
||||||
|
},
|
||||||
|
{ name: 'ЭкзоТерапия', text: 'Короткий 10-минутный этап, который можно встроить в персональную программу гостя.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '60–80 минут',
|
||||||
|
title: 'Время для себя',
|
||||||
|
devices: [
|
||||||
|
{ name: 'ЭкзоТерапия', text: 'Короткий премиальный этап восстановления внутри SPA-маршрута.' },
|
||||||
|
{ name: 'ЭкзоВодород', text: 'Автономная 40-минутная wellness-пауза в кресле.' },
|
||||||
|
{ name: 'ЭкзоПресс', text: 'Пассивная процедура, которую удобно сочетать со SPA и отдыхом.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: 'до 90 минут',
|
||||||
|
title: 'Индивидуальная программа',
|
||||||
|
devices: [
|
||||||
|
{ name: 'ЭкзоТерапия', text: 'Короткий флагманский этап программы.' },
|
||||||
|
{ name: 'ЭкзоТекар', text: 'Индивидуальная работа специалиста с выбранными зонами.' },
|
||||||
|
{ name: 'ЭкзоПресс', text: 'Потоковый пассивный формат для ног.' },
|
||||||
|
{
|
||||||
|
name: 'ЭкзоВодород',
|
||||||
|
text: 'Автономная wellness-пауза; состав и последовательность выбираются под сценарий гостя и формат отеля.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Почему Экзо */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const whyItems = [
|
||||||
|
{
|
||||||
|
icon: 'tech',
|
||||||
|
title: 'Российские технологии',
|
||||||
|
text: 'Технологичный формат восстановления и премиального сервиса для гостиничной инфраструктуры.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'factory',
|
||||||
|
title: 'Собственное производство в Тольятти',
|
||||||
|
text: 'Оборудование, визуальная концепция и модель внедрения собраны в единую систему Экзо Групп.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'launch',
|
||||||
|
title: 'Готовая модель запуска',
|
||||||
|
text: 'Подбор формата, включение услуг в SPA-прайс и приложение, обучение операторов и ресепшен, сценарии продаж и материалы запуска.',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-page anchors scroll to the target minus the fixed header, exactly as the
|
||||||
|
* original landing did. The header height is read at click time because it
|
||||||
|
* changes with the breakpoint and with the logo's fluid width.
|
||||||
|
*/
|
||||||
|
export function useAnchorScroll(headerRef: RefObject<HTMLElement | null>) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (event: MouseEvent) => {
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return
|
||||||
|
|
||||||
|
const link = (event.target as Element | null)?.closest('a[href^="#"]')
|
||||||
|
const href = link?.getAttribute('href')
|
||||||
|
if (!href || href === '#') return
|
||||||
|
|
||||||
|
const target = document.querySelector(href)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const offset = (headerRef.current?.offsetHeight ?? 0) + 8
|
||||||
|
window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - offset, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', onClick)
|
||||||
|
return () => document.removeEventListener('click', onClick)
|
||||||
|
}, [headerRef])
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll-in animation, matching the original page: fires once at 12% visibility
|
||||||
|
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
|
||||||
|
* that `prefers-reduced-motion` can neutralise it in one place.
|
||||||
|
*/
|
||||||
|
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
|
||||||
|
const ref = useRef<T>(null)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (!element || visible) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue
|
||||||
|
setVisible(true)
|
||||||
|
observer.unobserve(entry.target)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.12 },
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(element)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header gains its blurred background as soon as the page leaves the very
|
||||||
|
* top — 18px, same threshold as the original landing.
|
||||||
|
*/
|
||||||
|
export function useScrolled(threshold = 18) {
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > threshold)
|
||||||
|
onScroll()
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [threshold])
|
||||||
|
|
||||||
|
return scrolled
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Design tokens — ported 1:1 from the :root block of the original landing.
|
||||||
|
The default Tailwind palette and breakpoints are cleared so that only the
|
||||||
|
EXO design system is reachable from utility classes.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
@theme {
|
||||||
|
--color-*: initial;
|
||||||
|
--color-transparent: transparent;
|
||||||
|
--color-current: currentColor;
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-black: #000000;
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
--color-navy: #0a2540;
|
||||||
|
--color-navy-deep: #061b2f;
|
||||||
|
--color-navy-3: #0e3352;
|
||||||
|
--color-teal: #00c4b4;
|
||||||
|
--color-teal-bright: #39e0d2;
|
||||||
|
--color-teal-ink: #008f84;
|
||||||
|
--color-ice: #eafbf8;
|
||||||
|
|
||||||
|
/* Surfaces & text */
|
||||||
|
--color-paper: #f5fafb;
|
||||||
|
--color-ink: #0a2540;
|
||||||
|
--color-muted: #60778b;
|
||||||
|
--color-footer: #041421;
|
||||||
|
|
||||||
|
/* Economics section palette (shared with the fitness landing) */
|
||||||
|
--color-econ-bg: #f4fafb;
|
||||||
|
--color-econ-teal-deep: #00a99a;
|
||||||
|
--color-econ-ice: #eaf9f7;
|
||||||
|
--color-econ-red: #f0656b;
|
||||||
|
--color-econ-red-pale: #ffd7d9;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--radius-md: 18px;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--radius-xl: 32px;
|
||||||
|
|
||||||
|
/* Elevation */
|
||||||
|
--shadow-deep: 0 28px 90px rgb(3 26 43 / 0.18);
|
||||||
|
|
||||||
|
/* Layout rhythm */
|
||||||
|
--container-page: 1200px;
|
||||||
|
--spacing-section: clamp(68px, 8vw, 112px);
|
||||||
|
--spacing-section-compact: clamp(60px, 6vw, 88px);
|
||||||
|
|
||||||
|
/* Breakpoints — the original used 640 and 900 */
|
||||||
|
--breakpoint-*: initial;
|
||||||
|
--breakpoint-sm: 640px;
|
||||||
|
--breakpoint-md: 900px;
|
||||||
|
|
||||||
|
/* "Inter Variable" is the family Fontsource registers; the rest of the chain
|
||||||
|
is the original stack, kept for the swap window and for the few glyphs
|
||||||
|
(→ ↗ ↘) that Inter's subsets do not carry. */
|
||||||
|
--font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The original had four `max-width` media queries that do not line up with the
|
||||||
|
min-width scale above; they are exposed as named variants instead of
|
||||||
|
arbitrary values so the intent stays readable in the markup. */
|
||||||
|
@custom-variant tiny (@media (max-width: 430px));
|
||||||
|
@custom-variant phone (@media (max-width: 480px));
|
||||||
|
@custom-variant compact (@media (max-width: 620px));
|
||||||
|
@custom-variant narrow (@media (max-width: 780px));
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
background: var(--color-navy-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgb(0 196 180 / 0.28);
|
||||||
|
color: var(--color-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preflight zeroes the type scale; the original relied on these exact values
|
||||||
|
for every heading on the page, so they are restored once, here. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
max-width: 900px;
|
||||||
|
font-size: clamp(42px, 8vw, 86px);
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: clamp(34px, 5vw, 58px);
|
||||||
|
font-weight: 740;
|
||||||
|
line-height: 1.03;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: clamp(21px, 2.4vw, 28px);
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.14;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Scroll-triggered entrance. A class rather than utilities because it is
|
||||||
|
applied to ~40 elements and toggled from a shared IntersectionObserver. */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s ease,
|
||||||
|
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card rows that scroll horizontally below 900px and become a plain grid
|
||||||
|
above it. `grid-auto-columns` has no utility equivalent, and the row is
|
||||||
|
reused by five sections, so it stays a single component class. The desktop
|
||||||
|
column count is applied per section with `md:grid-cols-*`. */
|
||||||
|
.scroll-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(82%, 1fr);
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-inline: contain;
|
||||||
|
scroll-snap-type: inline mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 2px 1px 18px;
|
||||||
|
margin-right: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-cards::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-cards > * {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.scroll-cards {
|
||||||
|
grid-auto-columns: minmax(43%, 1fr);
|
||||||
|
margin-right: -24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.scroll-cards {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-auto-columns: auto;
|
||||||
|
overflow: visible;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero backdrop. Two stacked scrims that utilities can only express as an
|
||||||
|
unreadable arbitrary value; the horizontal one takes over from 900px. */
|
||||||
|
.hero-scrim {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(4 21 35 / 0.44) 0%, rgb(4 21 35 / 0.18) 32%, rgb(4 21 35 / 0.86) 100%),
|
||||||
|
linear-gradient(90deg, rgb(4 21 35 / 0.92) 0%, rgb(4 21 35 / 0.69) 46%, rgb(4 21 35 / 0.1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.hero-scrim {
|
||||||
|
background: linear-gradient(90deg, rgb(4 21 35 / 0.95) 0%, rgb(4 21 35 / 0.68) 48%, rgb(4 21 35 / 0.08) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-panel-surface {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),
|
||||||
|
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-shell {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 20%, rgb(0 196 180 / 0.2), transparent 27%),
|
||||||
|
linear-gradient(180deg, var(--color-navy) 0%, var(--color-navy-deep) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-surface {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 15%, rgb(0 196 180 / 0.22), transparent 28%),
|
||||||
|
radial-gradient(circle at 92% 88%, rgb(0 196 180 / 0.18), transparent 30%),
|
||||||
|
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint 52px grid, faded out towards the bottom of the CTA block. */
|
||||||
|
.cta-grid-overlay {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 52px 52px;
|
||||||
|
mask-image: linear-gradient(to bottom, black, transparent 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-panel-loss {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 0%, rgb(240 101 107 / 0.16), transparent 30%),
|
||||||
|
linear-gradient(145deg, var(--color-navy-deep), var(--color-navy));
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-calc-surface {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 90% 0%, rgb(0 196 180 / 0.25), transparent 34%),
|
||||||
|
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Market charts. SVG presentation attributes with transitions that are
|
||||||
|
driven by the `.is-visible` state of the surrounding reveal. */
|
||||||
|
.chart-gridline {
|
||||||
|
stroke: rgb(255 255 255 / 0.1);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-teal);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-dasharray: 500;
|
||||||
|
stroke-dashoffset: 500;
|
||||||
|
transition: stroke-dashoffset 1.1s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-dot {
|
||||||
|
fill: var(--color-teal-bright);
|
||||||
|
opacity: 0;
|
||||||
|
transform-origin: center;
|
||||||
|
transition: opacity 0.35s ease 0.65s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar {
|
||||||
|
fill: var(--color-teal);
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: bottom;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar--muted {
|
||||||
|
fill: rgb(255 255 255 / 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-visible .chart-line {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-visible .chart-dot {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-visible .chart-bar {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honeypot: must stay focusable-but-invisible, so `hidden` is not an option. */
|
||||||
|
@utility honeypot {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -9999px !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-line {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-dot {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bar {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cx(...values: (string | false | null | undefined)[]): string {
|
||||||
|
return values.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
|
||||||
|
|
||||||
|
export const formatNumber = (value: number) => money.format(value)
|
||||||
|
export const formatMoney = (value: number) => `${money.format(value)} ₽`
|
||||||
|
|
||||||
|
/** Mirrors the original calculator's tolerant number parsing. */
|
||||||
|
export function parseAmount(raw: string): number {
|
||||||
|
return Number(String(raw || '').replace(/\s/g, '').replace(',', '.')) || 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
|
||||||
|
|
||||||
|
const ENDPOINT = '/api/leads/hotels'
|
||||||
|
const DRAFT_KEY = 'exo_hotel_lead_draft'
|
||||||
|
|
||||||
|
/** Query-string UTM tags, forwarded to amoCRM with the lead. */
|
||||||
|
function collectUtm(): Record<string, string> {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const utm: Record<string, string> = {}
|
||||||
|
for (const key of utmKeys) {
|
||||||
|
const value = params.get(key)
|
||||||
|
if (value) utm[key] = value
|
||||||
|
}
|
||||||
|
return utm
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitResult {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
fields?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitLead(
|
||||||
|
form: LeadFormId,
|
||||||
|
values: Omit<LeadInput, 'form' | 'utm' | 'page' | 'referrer'>,
|
||||||
|
): Promise<SubmitResult> {
|
||||||
|
const payload: LeadInput = {
|
||||||
|
...values,
|
||||||
|
form,
|
||||||
|
page: window.location.pathname,
|
||||||
|
referrer: document.referrer || undefined,
|
||||||
|
utm: collectUtm(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as LeadResponse | null
|
||||||
|
|
||||||
|
if (!response.ok || !body?.ok) {
|
||||||
|
// The page must never lose a lead just because the CRM is down.
|
||||||
|
saveDraft(payload)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
body && !body.ok
|
||||||
|
? body.error
|
||||||
|
: 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
|
||||||
|
fields: body && !body.ok ? body.fields : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dataLayer = window.dataLayer ?? []
|
||||||
|
window.dataLayer.push({ event: 'hotel_lead_sent', form })
|
||||||
|
localStorage.removeItem(DRAFT_KEY)
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
saveDraft(payload)
|
||||||
|
console.warn('Lead endpoint error', error, payload)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft(payload: LeadInput) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
|
||||||
|
} catch {
|
||||||
|
// Private-mode browsers throw on write; the visible error message is enough.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
dataLayer?: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
// Self-hosted so the landing stays self-contained and makes no third-party
|
||||||
|
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
|
||||||
|
// cyrillic are actually downloaded for this page.
|
||||||
|
import '@fontsource-variable/inter'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const container = document.getElementById('root')
|
||||||
|
if (!container) throw new Error('#root is missing from index.html')
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src", "shared"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.server.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["server/src", "shared", "scripts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Set VITE_BASE_PATH=/hotels/ when the site is mounted on a sub-path, so
|
||||||
|
// that asset URLs in the built HTML resolve against it.
|
||||||
|
base: process.env.VITE_BASE_PATH ?? '/',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist/client',
|
||||||
|
emptyOutDir: true,
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||