diff --git a/hotel/.claude/launch.json b/hotel/.claude/launch.json
new file mode 100644
index 0000000..1a3b376
--- /dev/null
+++ b/hotel/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "hotel-dev",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev:client"],
+ "port": 5174
+ }
+ ]
+}
diff --git a/hotel/.env.example b/hotel/.env.example
new file mode 100644
index 0000000..54ef4fd
--- /dev/null
+++ b/hotel/.env.example
@@ -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
diff --git a/hotel/.gitignore b/hotel/.gitignore
new file mode 100644
index 0000000..23fc33a
--- /dev/null
+++ b/hotel/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+.env
+.env.local
+*.local
+.DS_Store
diff --git a/hotel/README.md b/hotel/README.md
new file mode 100644
index 0000000..ac9c0d1
--- /dev/null
+++ b/hotel/README.md
@@ -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-версии этих названий тоже нет.
diff --git a/hotel/index.html b/hotel/index.html
new file mode 100644
index 0000000..1c91990
--- /dev/null
+++ b/hotel/index.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ ЭкзоОтель — пространство восстановления для гостей | Экзо Групп
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hotel/legacy/index.html b/hotel/legacy/index.html
new file mode 100644
index 0000000..df4361d
--- /dev/null
+++ b/hotel/legacy/index.html
@@ -0,0 +1,1305 @@
+
+
+
+
+
+
+ЭкзоОтель — пространство восстановления для гостей | Экзо Групп
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Готовый формат внутри отеля
+
ЭкзоОтель: Пространство восстановления для гостей
+
Кабинет восстановления, расслабления и дополнительной выручки внутри отеля
+
+
+
4 аппарата единое пространство восстановления
+
1 оператор при дисциплинированном расписании
+
12 часов базовый режим работы кабинета
+
+
+
+
+
+
+
+
+
Бизнес-логика
+
Почему это выгодно именно вашему отелю
+
+
Монетизируйте усталость гостя в моменте её проявления. Зачастую гость не планирует расслабление за месяц до отпуска. Эта потребность рождается внутри отеля — после активного дня или дороги, позволяя вам собирать дополнительную выручку без сложных предварительных продаж.
+
+
+
+
+
+
Загрузка отелей снижается — ценность каждого гостя становится важнее
+
Гостевой поток ещё растёт, но предложение увеличивается быстрее, средняя загрузка в прогнозе 2026г. снижается с 62,0% до 59,1%, а рост RevPAR отстаёт от роста ADR. Отелю сложнее расти только за счёт номера.
+
+
+
+Гостевой поток, 2025 92 млн
+
+
+
+
+
+
+
+
+Предложение на рынке +14%
+
+
+
+>33 38
+
+
+
+
+Средняя загрузка, 2026г. −2,9 п.п.
+
+
+
+62,0% 59,1%
+
+
+
+
+Доходность номера, 2026г. разрыв роста
+
+
+
++5,2% +2,5%
+
+
+
+
+
+
LTV текущего гостя Больше поводов купить SPA / wellness-услугу, включить восстановление в пакет проживания и вернуться за повторным опытом.
+
Новые гости Выразительное пространство восстановления усиливает предложение отеля и становится дополнительным аргументом при выборе поездки.
+
Текущий поток Дополнительная точка выручки внутри действующей инфраструктуры — без расширения номерного фонда.
+
+
Показатели и формулировки — из презентации «ЭкзоОтель», 2025–2026г.. Влияние на LTV и привлечение гостей зависит от упаковки, продвижения и операционной загрузки.
+
RevPAR Revenue per Available Room — выручка на один доступный номер.
+
ADR Average Daily Rate — средняя цена проданного номера.
+
LTV Lifetime Value — совокупная ценность гостя за весь период отношений с отелем.
+
+
+
+
+
+
+
+
+ Экономика текущего гостевого потока
+ Номер уже продан. Следующая выручка — внутри маршрута гостя
+ Средняя загрузка снижается с 62,0% до 59,1%, а средняя цена проданного номера растёт быстрее выручки на один доступный номер. Это означает, что повышение стоимости проживания не полностью превращается в рост доходности. ЭкзоОтель добавляет отдельный платный сервис к уже существующему гостевому потоку.
+
+
+
−2,9 процентного пункта снижения загрузки с 62,0% до 59,1% в прогнозе на 2026 год
+
2,7 процентного пункта — разрыв темпов роста цена проданного номера +5,2%; выручка на доступный номер +2,5%
+
+14% рост предложения конкуренция за гостя усиливается
+
92 млн гостевой поток 2025 спрос есть — важна глубина монетизации
+
+
+
+ ↘ Где деньги остаются нереализованными
+
+
01 Доход зависит в основном от номера При снижении загрузки пустой номер уже нельзя компенсировать одним повышением средней цены проданного номера.
+
02 Потребность возникает, но не продаётся Усталость после дороги, лыж, прогулок или тренировок появляется уже в отеле — без recovery-меню чек не возникает.
+
03 Дополнительный расход уходит наружу Гость покупает массаж и восстановление вне отеля либо вовсе отказывается от услуги.
+
+
+
+ ↗ На чём зарабатывает отель с Экзо
+
+
01 Разовые программы в момент спроса Гость покупает понятный сценарий: после дороги, для ног, спины, SPA или активного дня.
+
02 Пакеты и upgrade к проживанию Recovery добавляется к SPA, фитнесу, premium-тарифу номера или программе активного отдыха.
+
03 Повторный опыт и рост LTV Знакомый wellness-ритуал становится причиной купить больше услуг и вернуться в отель.
+
+
+
+
+
Операционная база уже позволяет строить отдельную точку выручки
+
+
4 аппарата разные сценарии гостя
+
1 оператор основное участие — ЭкзоТекар
+
12 часов базовый режим расписания
+
до 48 аппарат-часов в день до учёта буферов
+
+
48 аппарат-часов — технический ориентир: 4 аппарата × 12 часов. Фактическая пропускная способность зависит от меню программ, сочетания процедур, санитарных интервалов и участия специалиста.
+
+
+
Калькулятор сценария Посчитайте дополнительную выручку на своих тарифах Введите фактическое число оплаченных программ, средний чек и рабочие дни.
+
+
+
+
Дополнительная выручка / месяц — Введите программы и чек
+
Дополнительная выручка / год — до вычета расходов
+
+
+
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка рассчитываются после аудита отеля.
+
+
+
+
+
+
+
+
Потребность гостя
+
Портрет гостя отеля
+
Кабинет востребован у гостей, которые хотят восстановиться, не меняя привычный маршрут отдыха или поездки.
+
+
+
+
+
+
+
+
+
+
Что можно купить
+
Комплект оборудования для отеля
+
+
Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором.
+
+
+
+
+
+
Как работает кабинет внутри отеля
+
Отель получает не просто четыре аппарата, а рабочую модель: помещение, расписание, меню программ, обучение команды и измеримые показатели пилота.
+
+
+
12–45 м² Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.
+
1 оператор Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.
+
12 часов Расписание с буферами на подготовку места, сопровождение гостя и санитарную обработку.
+
+
+
+
+
Гость покупает не название технологии, а понятный сервис: восстановление, лёгкость и премиальный опыт внутри отеля. Медицинские формулировки и протоколы применяются только при наличии лицензии, профильных специалистов и утверждённых протоколов.
+
+
+
+
+
+
+
Партнёр внедрения
+
Почему Экзо Групп
+
Отель получает четыре аппарата, визуальную концепцию, меню программ, расчёт экономики, формат с одним специалистом, обучение, чек-листы запуска и сценарии для SPA и ресепшен.
+
+
+
+
+Не просто оборудование — готовое направление внутри отеля
+Визуальная концепция, меню программ, формат с одним специалистом, обучение, чек-листы и сценарии для SPA и ресепшен.
+
+
+
+
+
+Российские технологии Технологичный формат восстановления и премиального сервиса для гостиничной инфраструктуры.
+
+
+
+Собственное производство в Тольятти Оборудование, визуальная концепция и модель внедрения собраны в единую систему Экзо Групп.
+
+
+
+Готовая модель запуска Подбор формата, включение услуг в SPA-прайс и приложение, обучение операторов и ресепшен, сценарии продаж и материалы запуска.
+
+
+
+
+
+
+
+
+
+
+
Следующий шаг
+
Подберём оборудование под формат вашего отеля
+
Оставьте контакты — специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом окупаемости и меню программ.
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/hotel/package-lock.json b/hotel/package-lock.json
new file mode 100644
index 0000000..6c54f41
--- /dev/null
+++ b/hotel/package-lock.json
@@ -0,0 +1,3715 @@
+{
+ "name": "exo-hotel-landing",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "exo-hotel-landing",
+ "version": "1.0.0",
+ "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"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fontsource-variable/inter": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
+ "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
+ "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.4.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
+ "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
+ "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/concurrently": {
+ "version": "10.0.5",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
+ "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "5.6.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.9.0",
+ "supports-color": "10.2.2",
+ "tree-kill": "1.2.2",
+ "yargs": "18.0.0"
+ },
+ "bin": {
+ "conc": "dist/bin/index.js",
+ "concurrently": "dist/bin/index.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.5",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
+ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shell-quote": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.23.12",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
+ "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/hotel/package.json b/hotel/package.json
new file mode 100644
index 0000000..dcca9ed
--- /dev/null
+++ b/hotel/package.json
@@ -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"
+ }
+}
diff --git a/hotel/public/favicon.png b/hotel/public/favicon.png
new file mode 100644
index 0000000..beddb74
Binary files /dev/null and b/hotel/public/favicon.png differ
diff --git a/hotel/public/og-image.jpg b/hotel/public/og-image.jpg
new file mode 100644
index 0000000..8d83481
Binary files /dev/null and b/hotel/public/og-image.jpg differ
diff --git a/hotel/scripts/amo-check.ts b/hotel/scripts/amo-check.ts
new file mode 100644
index 0000000..6cfc7e4
--- /dev/null
+++ b/hotel/scripts/amo-check.ts
@@ -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.')
diff --git a/hotel/server/src/amocrm.ts b/hotel/server/src/amocrm.ts
new file mode 100644
index 0000000..26f9edb
--- /dev/null
+++ b/hotel/server/src/amocrm.ts
@@ -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
+ private contactFields?: Promise
+
+ 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(
+ path: string,
+ init: { method?: string; body?: unknown; query?: Record } = {},
+ attempt = 1,
+ ): Promise {
+ 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(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(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 {
+ 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 {
+ this.leadFields ??= this.fetchFields('leads')
+ return this.leadFields
+ }
+
+ getContactFields(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const embedded: Record = { 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 {
+ 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 {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/hotel/server/src/config.ts b/hotel/server/src/config.ts
new file mode 100644
index 0000000..9c56723
--- /dev/null
+++ b/hotel/server/src/config.ts
@@ -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',
+}
diff --git a/hotel/server/src/index.ts b/hotel/server/src/index.ts
new file mode 100644
index 0000000..7b44874
--- /dev/null
+++ b/hotel/server/src/index.ts
@@ -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 = {}
+ 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)',
+ )
+})
diff --git a/hotel/server/src/lead-mapper.ts b/hotel/server/src/lead-mapper.ts
new file mode 100644
index 0000000..af031b2
--- /dev/null
+++ b/hotel/server/src/lead-mapper.ts
@@ -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 = {
+ 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 = {
+ 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
+
+/** 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()
+
+ 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 = {
+ 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}`
+}
diff --git a/hotel/server/src/lead-service.ts b/hotel/server/src/lead-service.ts
new file mode 100644
index 0000000..cce035a
--- /dev/null
+++ b/hotel/server/src/lead-service.ts
@@ -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([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
+
+ constructor(private readonly amo: AmoClient) {}
+
+ private getFieldMap(): Promise {
+ this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
+ return this.fieldMap
+ }
+
+ async submit(payload: LeadPayload): Promise {
+ 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 }
+ }
+}
diff --git a/hotel/server/src/rate-limit.ts b/hotel/server/src/rate-limit.ts
new file mode 100644
index 0000000..9a01b13
--- /dev/null
+++ b/hotel/server/src/rate-limit.ts
@@ -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()
+
+ 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),
+ }
+ }
+}
diff --git a/hotel/shared/lead.ts b/hotel/shared/lead.ts
new file mode 100644
index 0000000..c063b19
--- /dev/null
+++ b/hotel/shared/lead.ts
@@ -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
+/** What the client sends — before zod's optional/empty-string normalisation. */
+export type LeadInput = z.input
+
+export type LeadResponse =
+ | { ok: true; leadId: number; contactId: number }
+ | { ok: false; error: string; fields?: Record }
diff --git a/hotel/src/App.tsx b/hotel/src/App.tsx
new file mode 100644
index 0000000..cb8e15a
--- /dev/null
+++ b/hotel/src/App.tsx
@@ -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(null)
+ const scrolled = useScrolled()
+ useAnchorScroll(headerRef)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/hotel/src/assets/images/device-hydrogen.webp b/hotel/src/assets/images/device-hydrogen.webp
new file mode 100644
index 0000000..20f2d93
Binary files /dev/null and b/hotel/src/assets/images/device-hydrogen.webp differ
diff --git a/hotel/src/assets/images/device-press.webp b/hotel/src/assets/images/device-press.webp
new file mode 100644
index 0000000..58248fc
Binary files /dev/null and b/hotel/src/assets/images/device-press.webp differ
diff --git a/hotel/src/assets/images/device-tecar.webp b/hotel/src/assets/images/device-tecar.webp
new file mode 100644
index 0000000..00f3eb7
Binary files /dev/null and b/hotel/src/assets/images/device-tecar.webp differ
diff --git a/hotel/src/assets/images/device-therapy.webp b/hotel/src/assets/images/device-therapy.webp
new file mode 100644
index 0000000..c1a64d8
Binary files /dev/null and b/hotel/src/assets/images/device-therapy.webp differ
diff --git a/hotel/src/assets/images/exo-logo.png b/hotel/src/assets/images/exo-logo.png
new file mode 100644
index 0000000..d1a7c79
Binary files /dev/null and b/hotel/src/assets/images/exo-logo.png differ
diff --git a/hotel/src/assets/images/hero-bg.webp b/hotel/src/assets/images/hero-bg.webp
new file mode 100644
index 0000000..feff49b
Binary files /dev/null and b/hotel/src/assets/images/hero-bg.webp differ
diff --git a/hotel/src/assets/images/scenario-active.webp b/hotel/src/assets/images/scenario-active.webp
new file mode 100644
index 0000000..f512710
Binary files /dev/null and b/hotel/src/assets/images/scenario-active.webp differ
diff --git a/hotel/src/assets/images/scenario-recovery.webp b/hotel/src/assets/images/scenario-recovery.webp
new file mode 100644
index 0000000..7b5476f
Binary files /dev/null and b/hotel/src/assets/images/scenario-recovery.webp differ
diff --git a/hotel/src/assets/images/scenario-spa.webp b/hotel/src/assets/images/scenario-spa.webp
new file mode 100644
index 0000000..f60acfa
Binary files /dev/null and b/hotel/src/assets/images/scenario-spa.webp differ
diff --git a/hotel/src/assets/images/scenario-wellness.webp b/hotel/src/assets/images/scenario-wellness.webp
new file mode 100644
index 0000000..2fa6dbb
Binary files /dev/null and b/hotel/src/assets/images/scenario-wellness.webp differ
diff --git a/hotel/src/components/BenefitsSection.tsx b/hotel/src/components/BenefitsSection.tsx
new file mode 100644
index 0000000..efd5cbd
--- /dev/null
+++ b/hotel/src/components/BenefitsSection.tsx
@@ -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 (
+
+
+
+
+
+ {benefits.map((benefit, index) => (
+
+ ))}
+
+
+ Листайте карточки в сторону
+
+
+
+
+ )
+}
+
+function BenefitCard({ num, title, text, index }: (typeof benefits)[number] & { index: number }) {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+ {num}
+ {title}
+ {text}
+
+ )
+}
+
+/**
+ * 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()
+
+ return (
+
+
+
+ Загрузка отелей снижается — ценность каждого гостя становится важнее
+
+
+ Гостевой поток ещё растёт, но предложение увеличивается быстрее, средняя загрузка в прогнозе 2026г. снижается
+ с 62,0% до 59,1%, а рост RevPAR отстаёт от роста ADR. Отелю сложнее расти только за счёт номера.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >33
+
+
+ 38
+
+
+
+
+
+
+
+
+
+
+ 62,0%
+
+
+ 59,1%
+
+
+
+
+
+
+
+
+
+
+ +5,2%
+
+
+ +2,5%
+
+
+
+
+
+
+ {marketImplications.map((item) => (
+
+ {item.title}
+ {item.text}
+
+ ))}
+
+
+
+ Показатели и формулировки — из презентации «ЭкзоОтель», 2025–2026 гг. Влияние на LTV и привлечение гостей
+ зависит от упаковки, продвижения и операционной загрузки.
+
+
+
+ {glossary.map((item) => (
+
+ {item.term}
+ {item.text}
+
+ ))}
+
+
+ )
+}
+
+function MetricCard({
+ label,
+ value,
+ foot,
+ children,
+}: {
+ label: string
+ value: string
+ foot: [string, string]
+ children: ReactNode
+}) {
+ return (
+
+
+ {label}
+ {value}
+
+ {children}
+
+ {foot[0]}
+ {foot[1]}
+
+
+ )
+}
diff --git a/hotel/src/components/Button.tsx b/hotel/src/components/Button.tsx
new file mode 100644
index 0000000..228fd7a
--- /dev/null
+++ b/hotel/src/components/Button.tsx
@@ -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 = {
+ 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, 'size'>) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function ButtonLink({
+ variant = 'primary',
+ size,
+ className,
+ children,
+ ...rest
+}: CommonProps & AnchorHTMLAttributes) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/hotel/src/components/EconomicsSection.tsx b/hotel/src/components/EconomicsSection.tsx
new file mode 100644
index 0000000..bbb979a
--- /dev/null
+++ b/hotel/src/components/EconomicsSection.tsx
@@ -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()
+ const metrics = useReveal()
+ const loss = useReveal()
+ const gain = useReveal()
+ const bridge = useReveal()
+
+ return (
+
+
+
+
+
+
+ Экономика текущего гостевого потока
+
+
+ Номер уже продан. Следующая выручка — внутри маршрута гостя
+
+
+ Средняя загрузка снижается с 62,0% до 59,1%, а средняя цена проданного номера растёт быстрее выручки на один
+ доступный номер. Это означает, что повышение стоимости проживания не полностью превращается в рост
+ доходности. ЭкзоОтель добавляет отдельный платный сервис к уже существующему гостевому потоку.
+
+
+
+
+ {econMetrics.map((metric) => (
+
+
+ {metric.value}
+
+ {metric.label}
+ {metric.note}
+
+ ))}
+
+
+
+
+
+
+ ↘
+
+ Где деньги остаются нереализованными
+
+
+ {econLoss.map((item) => (
+
+
{item.num}
+
+ {item.title}
+ {item.text}
+
+
+ ))}
+
+
+
+
+
+
+ ↗
+
+ На чём зарабатывает отель с Экзо
+
+
+ {econGain.map((item) => (
+
+
{item.num}
+
+ {item.title}
+ {item.text}
+
+
+ ))}
+
+
+
+
+
+
+ Операционная база уже позволяет строить отдельную точку выручки
+
+
+ {econOps.map((op) => (
+
+ {op.value}
+ {op.label}
+
+ ))}
+
+
+ 48 аппарат-часов — технический ориентир: 4 аппарата × 12 часов. Фактическая пропускная способность зависит
+ от меню программ, сочетания процедур, санитарных интервалов и участия специалиста.
+
+
+
+
+
+
+ )
+}
+
+/** `оплаченные программы в день × средний чек × рабочие дни = выручка в месяц` */
+function RevenueCalculator() {
+ const { ref, revealClass } = useReveal()
+ 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 (
+
+
+
Калькулятор сценария
+
+ Посчитайте дополнительную выручку на своих тарифах
+
+
+ Введите фактическое число оплаченных программ, средний чек и рабочие дни.
+
+
+
+
+
+
+ Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
+ рассчитываются после аудита отеля.
+
+
+ )
+}
+
+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 (
+
+
+ {label}
+
+ 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)]"
+ />
+
+ )
+}
+
+function CalcResult({ label, value, note }: { label: string; value: string; note: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+ {note}
+
+ )
+}
diff --git a/hotel/src/components/EquipmentSection.tsx b/hotel/src/components/EquipmentSection.tsx
new file mode 100644
index 0000000..23ce2bc
--- /dev/null
+++ b/hotel/src/components/EquipmentSection.tsx
@@ -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()
+ const note = useReveal()
+
+ return (
+
+
+
+
+
+
+
+ {devices.map((device, index) => (
+
+ ))}
+
+
+ Листайте оборудование в сторону
+
+
+
+
Как работает кабинет внутри отеля
+
+ Отель получает не просто четыре аппарата, а рабочую модель: помещение, расписание, меню программ, обучение
+ команды и измеримые показатели пилота.
+
+
+
+
+ {hotelModel.map((card) => (
+
+
+ {card.value}
+
+ {card.text}
+
+ ))}
+
+
+
+
+ Гость покупает не название технологии, а понятный сервис: восстановление, лёгкость и премиальный опыт внутри
+ отеля. Медицинские формулировки и протоколы применяются только при наличии лицензии, профильных специалистов и
+ утверждённых протоколов.
+
+
+
+ )
+}
+
+function DeviceCard({ name, role, duration, tech, text, image, alt, index }: (typeof devices)[number] & { index: number }) {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+
+
+
+
+
+
+
+
+ {duration}
+
+
{tech}
+
{text}
+
+
+ )
+}
diff --git a/hotel/src/components/FormField.tsx b/hotel/src/components/FormField.tsx
new file mode 100644
index 0000000..5d696d8
--- /dev/null
+++ b/hotel/src/components/FormField.tsx
@@ -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 (
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+export function TextField({
+ id,
+ label,
+ full,
+ ...rest
+}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
+ return (
+
+
+
+ )
+}
+
+export function TextareaField({
+ id,
+ label,
+ full,
+ ...rest
+}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
+ return (
+
+
+
+ )
+}
+
+/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
+export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
+ return (
+
+ Не заполнять
+ onChange(event.target.value)}
+ />
+
+ )
+}
diff --git a/hotel/src/components/FormatsSection.tsx b/hotel/src/components/FormatsSection.tsx
new file mode 100644
index 0000000..c4e58e9
--- /dev/null
+++ b/hotel/src/components/FormatsSection.tsx
@@ -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()
+
+ return (
+
+ )
+}
+
+function ScenarioCard({ index: label, title, text, image, alt, order }: (typeof scenarios)[number] & { order: number }) {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+
+
+
{label}
+
{title}
+
{text}
+
+
+ )
+}
diff --git a/hotel/src/components/GuestsSection.tsx b/hotel/src/components/GuestsSection.tsx
new file mode 100644
index 0000000..631f035
--- /dev/null
+++ b/hotel/src/components/GuestsSection.tsx
@@ -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 (
+
+
+
+
+
+ {guestPortraits.map((portrait, index) => (
+
+ ))}
+
+
+ Листайте карточки в сторону
+
+
+ )
+}
+
+function PortraitCard({
+ icon,
+ title,
+ text,
+ index,
+}: (typeof guestPortraits)[number] & { index: number }) {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+
+ {guestIcons[icon]}
+
+ {title}
+ {text}
+
+ )
+}
diff --git a/hotel/src/components/Hero.tsx b/hotel/src/components/Hero.tsx
new file mode 100644
index 0000000..8cc2866
--- /dev/null
+++ b/hotel/src/components/Hero.tsx
@@ -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()
+ const title = useReveal()
+ const subtitle = useReveal()
+ const actions = useReveal()
+ const facts = useReveal()
+
+ return (
+
+
+
+
+
+
+
+
+
+ Готовый формат внутри отеля
+
+
+
+ ЭкзоОтель: Пространство восстановления для гостей
+
+
+
+ Кабинет восстановления, расслабления и дополнительной выручки внутри отеля
+
+
+
+
+ Получить предложение
+
+
+
+ Подобрать оборудование
+
+
+
+
+ {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.
+
+
+ {fact.value}
+
+ {fact.label}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/hotel/src/components/Icons.tsx b/hotel/src/components/Icons.tsx
new file mode 100644
index 0000000..927ca09
--- /dev/null
+++ b/hotel/src/components/Icons.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export function ArrowRightIcon() {
+ return (
+
+
+
+ )
+}
+
+export function PhoneIcon() {
+ return (
+
+
+
+ )
+}
+
+export function ChevronsIcon() {
+ return (
+
+
+
+ )
+}
+
+/** Icons of the guest portraits, keyed by `guestPortraits[].icon`. */
+export const guestIcons = {
+ road: (
+
+
+
+ ),
+ active: (
+
+
+
+ ),
+ wellness: (
+
+
+
+
+ ),
+ business: (
+
+
+
+
+ ),
+}
+
+/** Icons of the "почему Экзо" list, keyed by `whyItems[].icon`. */
+export const whyIcons = {
+ tech: (
+
+
+
+
+ ),
+ factory: (
+
+
+
+
+ ),
+ launch: (
+
+
+
+
+ ),
+}
diff --git a/hotel/src/components/Layout.tsx b/hotel/src/components/Layout.tsx
new file mode 100644
index 0000000..12de0df
--- /dev/null
+++ b/hotel/src/components/Layout.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+/** Small uppercase kicker with the leading teal rule. */
+export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+type Tone = 'light' | 'dark'
+
+export function SectionLead({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * `.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()
+
+ if (!split) {
+ return (
+
+ {eyebrow}
+
{title}
+ {lead ? {lead} : null}
+
+ )
+ }
+
+ return (
+
+
+ {eyebrow}
+
{title}
+
+ {lead ? (
+
+ {lead}
+
+ ) : null}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+/** "Листайте карточки в сторону" — only shown while the row still scrolls. */
+export function ScrollHint({ tone = 'light', children }: { tone?: Tone; children: ReactNode }) {
+ return (
+ svg]:size-[16px]',
+ tone === 'dark' ? 'text-white/[0.52]' : 'text-muted',
+ )}
+ >
+
+ {children}
+
+ )
+}
diff --git a/hotel/src/components/LeadForm.tsx b/hotel/src/components/LeadForm.tsx
new file mode 100644
index 0000000..58f7957
--- /dev/null
+++ b/hotel/src/components/LeadForm.tsx
@@ -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()
+ const [values, setValues] = useState(EMPTY)
+ const [honeypot, setHoneypot] = useState('')
+ const [pending, setPending] = useState(false)
+ const [status, setStatus] = useState({ 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) {
+ 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 (
+
+ )
+}
diff --git a/hotel/src/components/RequestSection.tsx b/hotel/src/components/RequestSection.tsx
new file mode 100644
index 0000000..360621b
--- /dev/null
+++ b/hotel/src/components/RequestSection.tsx
@@ -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()
+
+ return (
+
+
+
+
+
+ Следующий шаг
+
Подберём оборудование под формат вашего отеля
+
+ Оставьте контакты — специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом
+ окупаемости и меню программ.
+
+
+
+
+
+
+ )
+}
diff --git a/hotel/src/components/SiteFooter.tsx b/hotel/src/components/SiteFooter.tsx
new file mode 100644
index 0000000..b1c2dcc
--- /dev/null
+++ b/hotel/src/components/SiteFooter.tsx
@@ -0,0 +1,22 @@
+import logo from '../assets/images/exo-logo.png'
+import { Container } from './Layout'
+
+export function SiteFooter() {
+ return (
+
+ )
+}
diff --git a/hotel/src/components/SiteHeader.tsx b/hotel/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..8a57793
--- /dev/null
+++ b/hotel/src/components/SiteHeader.tsx
@@ -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 }) {
+ return (
+
+ )
+}
diff --git a/hotel/src/components/WhyExoSection.tsx b/hotel/src/components/WhyExoSection.tsx
new file mode 100644
index 0000000..60d6a49
--- /dev/null
+++ b/hotel/src/components/WhyExoSection.tsx
@@ -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()
+
+ return (
+
+
+
+
+
+
+
+
+
+ Не просто оборудование — готовое направление внутри отеля
+
+
+ Визуальная концепция, меню программ, формат с одним специалистом, обучение, чек-листы и сценарии для SPA
+ и ресепшен.
+
+
+
+
+
+ {whyItems.map((item, index) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+function WhyItem({ icon, title, text, index }: (typeof whyItems)[number] & { index: number }) {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+
+ {whyIcons[icon]}
+
+
+
+ )
+}
diff --git a/hotel/src/data/content.ts b/hotel/src/data/content.ts
new file mode 100644
index 0000000..6a81b71
--- /dev/null
+++ b/hotel/src/data/content.ts
@@ -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
diff --git a/hotel/src/hooks/useAnchorScroll.ts b/hotel/src/hooks/useAnchorScroll.ts
new file mode 100644
index 0000000..27b01c8
--- /dev/null
+++ b/hotel/src/hooks/useAnchorScroll.ts
@@ -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) {
+ 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])
+}
diff --git a/hotel/src/hooks/useReveal.ts b/hotel/src/hooks/useReveal.ts
new file mode 100644
index 0000000..ce58032
--- /dev/null
+++ b/hotel/src/hooks/useReveal.ts
@@ -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() {
+ const ref = useRef(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' }
+}
diff --git a/hotel/src/hooks/useScrollState.ts b/hotel/src/hooks/useScrollState.ts
new file mode 100644
index 0000000..7278cc5
--- /dev/null
+++ b/hotel/src/hooks/useScrollState.ts
@@ -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
+}
diff --git a/hotel/src/index.css b/hotel/src/index.css
new file mode 100644
index 0000000..e1c01f5
--- /dev/null
+++ b/hotel/src/index.css
@@ -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);
+ }
+}
diff --git a/hotel/src/lib/cx.ts b/hotel/src/lib/cx.ts
new file mode 100644
index 0000000..a2fc089
--- /dev/null
+++ b/hotel/src/lib/cx.ts
@@ -0,0 +1,3 @@
+export function cx(...values: (string | false | null | undefined)[]): string {
+ return values.filter(Boolean).join(' ')
+}
diff --git a/hotel/src/lib/format.ts b/hotel/src/lib/format.ts
new file mode 100644
index 0000000..b085700
--- /dev/null
+++ b/hotel/src/lib/format.ts
@@ -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
+}
diff --git a/hotel/src/lib/lead.ts b/hotel/src/lib/lead.ts
new file mode 100644
index 0000000..69b6f9d
--- /dev/null
+++ b/hotel/src/lib/lead.ts
@@ -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 {
+ const params = new URLSearchParams(window.location.search)
+ const utm: Record = {}
+ 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
+}
+
+export async function submitLead(
+ form: LeadFormId,
+ values: Omit,
+): Promise {
+ 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[]
+ }
+}
diff --git a/hotel/src/main.tsx b/hotel/src/main.tsx
new file mode 100644
index 0000000..0fcd6ff
--- /dev/null
+++ b/hotel/src/main.tsx
@@ -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(
+
+
+ ,
+)
diff --git a/hotel/tsconfig.app.json b/hotel/tsconfig.app.json
new file mode 100644
index 0000000..0387723
--- /dev/null
+++ b/hotel/tsconfig.app.json
@@ -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"]
+}
diff --git a/hotel/tsconfig.json b/hotel/tsconfig.json
new file mode 100644
index 0000000..c3c5b3d
--- /dev/null
+++ b/hotel/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.server.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/hotel/tsconfig.node.json b/hotel/tsconfig.node.json
new file mode 100644
index 0000000..b499a2f
--- /dev/null
+++ b/hotel/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"]
+}
diff --git a/hotel/tsconfig.server.json b/hotel/tsconfig.server.json
new file mode 100644
index 0000000..1359696
--- /dev/null
+++ b/hotel/tsconfig.server.json
@@ -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"]
+}
diff --git a/hotel/vite.config.ts b/hotel/vite.config.ts
new file mode 100644
index 0000000..da79ac5
--- /dev/null
+++ b/hotel/vite.config.ts
@@ -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 },
+ },
+ },
+})