diff --git a/medcenterpersonal/.claude/launch.json b/medcenterpersonal/.claude/launch.json
new file mode 100644
index 0000000..76593d4
--- /dev/null
+++ b/medcenterpersonal/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "medcenter-no-physio-dev",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev:client"],
+ "port": 5175
+ }
+ ]
+}
diff --git a/medcenterpersonal/.env.example b/medcenterpersonal/.env.example
new file mode 100644
index 0000000..2e0bd93
--- /dev/null
+++ b/medcenterpersonal/.env.example
@@ -0,0 +1,35 @@
+# ---------------------------------------------------------------------------
+# Public (baked into the client bundle at build time — never put secrets here)
+# ---------------------------------------------------------------------------
+VITE_SITE_URL=https://exodevices.ru/medical-centers/no-physiotherapy
+
+# Sub-path the site is served from, if any. Leave as "/" when it sits at the
+# domain root; set to "/medical-centers/no-physiotherapy/" when it is mounted
+# under that path.
+VITE_BASE_PATH=/
+
+# ---------------------------------------------------------------------------
+# Server
+# ---------------------------------------------------------------------------
+# 3000 and 3001 belong to the fitnes/medcenter and hotel landings.
+PORT=3002
+
+# ---------------------------------------------------------------------------
+# 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=medcenter-no-physio-landing
diff --git a/medcenterpersonal/.gitignore b/medcenterpersonal/.gitignore
new file mode 100644
index 0000000..23fc33a
--- /dev/null
+++ b/medcenterpersonal/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+.env
+.env.local
+*.local
+.DS_Store
diff --git a/medcenterpersonal/README.md b/medcenterpersonal/README.md
new file mode 100644
index 0000000..a5f5f4d
--- /dev/null
+++ b/medcenterpersonal/README.md
@@ -0,0 +1,198 @@
+# Физиотерапия с нуля — лендинг для медицинских центров
+
+React-приложение лендинга MedCbezFiz (медицинский центр, где физиотерапии ещё
+нет). Каждая заявка создаёт сделку в amoCRM (воронка `10980758`) и контакт, если
+его ещё нет.
+
+Стек: **Vite + React 19 + TypeScript + Tailwind CSS v4**, API — **Express 5**.
+
+---
+
+## Быстрый старт
+
+```bash
+npm install
+cp .env.example .env # заполнить AMO_SUBDOMAIN и AMO_LONG_LIVED_TOKEN
+npm run dev
+```
+
+`npm run dev` поднимает Vite на `http://localhost:5175` и API на `:3002`;
+запросы `/api/*` проксируются с фронтенда на API.
+
+Порты выбраны так, чтобы все лендинги можно было запускать одновременно:
+`fitnes` и `medcenter` — 5173/3000, `hotel` — 5174/3001, этот — 5175/3002.
+
+## Продакшен
+
+```bash
+npm run build # сборка клиента в dist/client
+npm start # Express отдаёт dist/client и обрабатывает /api/*
+```
+
+Один процесс Node на любом VPS. Порт — `PORT` (по умолчанию 3002).
+
+---
+
+## amoCRM
+
+### 1. Получить долгосрочный токен
+
+1. amoCRM → **Настройки → Интеграции → Создать интеграцию → Внешняя интеграция**.
+2. Название любое (например «Сайт medcenter без физио»), redirect URI — любой
+ рабочий адрес сайта: для долгосрочного токена он не используется.
+3. Права доступа: достаточно **Сделки, Контакты** (чтение и запись).
+4. Сохранить, открыть интеграцию → вкладка **«Ключи и скопы»** →
+ **«Генерировать токен»** (долгосрочный, действует ~1 год).
+5. Скопировать токен в `.env` в `AMO_LONG_LIVED_TOKEN`, поддомен — в
+ `AMO_SUBDOMAIN` (для `https://exotherapy.amocrm.ru` это `exotherapy`).
+
+Токен читается только на сервере и никогда не попадает в браузерный бандл.
+Пометьте в календаре дату истечения — токен нужно перевыпустить через год.
+
+### 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 заранее не нужно.
+
+Эндпоинт — `POST /api/leads/medical-centers-no-physio` (адрес взят из исходного
+лендинга).
+
+Если 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/продакшен-сервера (`3002`) |
+| `VITE_SITE_URL` | нет | Канонический адрес, подставляется в `canonical` и `og:` |
+| `VITE_BASE_PATH` | нет | Подпуть размещения, например `/medical-centers/no-physiotherapy/` (по умолчанию `/`) |
+
+---
+
+## Структура
+
+```
+index.html точка входа Vite (meta, Open Graph, JSON-LD)
+src/
+ components/ секции лендинга и UI-примитивы
+ data/content.ts весь текстовый контент страницы
+ hooks/ появление секций при скролле, состояние шапки
+ lib/ отправка заявки, маска телефона
+ assets/images/ 12 изображений, извлечённых из исходного HTML
+ index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
+shared/lead.ts схема заявки (zod), общая для клиента и сервера
+server/src/ Express API + клиент amoCRM
+scripts/amo-check.ts диагностика подключения к amoCRM
+legacy/index.html исходный однофайловый лендинг (визуальный эталон)
+legacy/README.md исходная инструкция к однофайловой версии
+```
+
+### Дизайн-система
+
+Цвета, радиусы, тени, брейкпоинты и вертикальный ритм заданы токенами в
+`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
+(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
+Брейкпоинты соответствуют исходной вёрстке: `sm` 600, `md` 760, `lg` 980, плюс
+`max-width`-варианты `stack` (1050), `narrow` (760), `compact` (560),
+`phone` (520), `tiny` (430). Они объявлены от широкого к узкому — Tailwind
+выводит варианты в порядке объявления, и более узкий должен идти позже, чтобы
+выигрывать.
+
+Горизонтальные ряды карточек (рынок, обещания, программы, оборудование,
+форматы, план запуска) используют класс `.scroll-cards`: на телефоне это лента
+со snap-скроллом, а на нужной ширине секция сама возвращает её в обычную сетку
+через `sm:grid-still` / `md:grid-still`.
+
+Два места намеренно выбиваются из общей сетки, повторяя оригинал:
+
+* блок «Сценарии выручки» (`.fin-model`) сохраняет отступ 32 px на всех
+ ширинах — в исходном CSS правило `.fin-model .container` перебивает общее
+ расширение контейнера до 48 px с 760 px;
+* план запуска (`.timeline`) переходит в две колонки на 900 px, а не на 760 px,
+ как остальные секции.
+
+### Шрифты
+
+Inter подключён локально пакетом `@fontsource-variable/inter` — внешних запросов
+(Google Fonts и т.п.) страница не делает. Это вариативный шрифт с диапазоном
+100–900, поэтому нестандартные веса из макета (720, 760, 790, 850) отрисовываются
+по-настоящему, а не округляются до `bold`.
+
+Символы `≈ − ×` не входят в подсеты Inter у Fontsource, поэтому для них всегда
+используется системный фолбэк. Это заметно только при очень крупном кегле.
+
+`Manrope` и `Segoe UI` остались в стеке `--font-sans` как фолбэк, но отдельно не
+загружаются.
+
+### Экономика направления
+
+Цифры блока «Сценарии выручки» — статические данные из `src/data/content.ts`,
+как в исходном лендинге. Калькулятора на странице нет: расчёт делается на
+аудите.
+
+---
+
+## Соответствие оригиналу
+
+Текст страницы сверялся с `legacy/index.html` построчно: приложение
+рендерилось в статический HTML, из обоих файлов извлекался видимый текст и
+сравнивался. Совпадение полное — единственное расхождение — скрытая подпись
+honeypot-поля, которой в оригинале нет.
+
+### Что сделано иначе
+
+* **Две фотографии аппаратов** (ЭкзоЛазер В и ЭкзоИмпульс) в исходном файле были
+ PNG на 1,5 МБ и 750 КБ — здесь они пережаты в webp, как и остальные снимки
+ оборудования. Исходники побайтово совпадают с медцентровским лендингом,
+ поэтому переиспользованы уже готовые webp-версии. Папка ассетов — 664 КБ
+ вместо 2,8 МБ.
+* **Мёртвый CSS не переносился.** В оригинале лежат 39 правил
+ `.patient-economics` / `.base-economics-card`, на которые в разметке нет ни
+ одного элемента. Там же — обработчик анимации счётчиков, который ищет
+ `[data-counter]`; таких элементов на странице тоже нет.
+* **Honeypot** получил визуально скрытую подпись — она нужна скринридерам и
+ скрыта утилитой `honeypot`, а не `display:none`, иначе поле перестанет быть
+ фокусируемым и ловушка не сработает.
+
+### Что воспроизведено как есть
+
+* Блок `.system-bottom` в секции «Готовое направление» сохраняет верхнюю
+ линию и отступы, хотя сетки карточек над ней в этом лендинге уже нет — так же
+ выглядит и оригинал.
+* Мелкие подписи остались в исходных цветах: надзаголовки секций `#008F84` на
+ светлом фоне дают 3,79:1, источники цифр `#7E93A3` на белом — 3,19:1. Это
+ ниже 4,5:1, но соответствует оригиналу и остальным лендингам EXO; менять
+ палитру в одном лендинге не стали.
+
+---
+
+## Известные особенности
+
+* `og:image` отдаётся по абсолютному адресу `VITE_SITE_URL` + `/og-image.webp`;
+ при смене домена обновите `VITE_SITE_URL` перед сборкой.
+* Шапка прозрачна над первым экраном и получает размытую подложку после
+ 18 px прокрутки — как в оригинале (в лендинге medcenter это правило было
+ перебито, и подложка там видна всегда).
diff --git a/medcenterpersonal/index.html b/medcenterpersonal/index.html
new file mode 100644
index 0000000..6502d2e
--- /dev/null
+++ b/medcenterpersonal/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+ Физиотерапия с нуля для медицинского центра | Экзо Групп
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/medcenterpersonal/legacy/README.md b/medcenterpersonal/legacy/README.md
new file mode 100644
index 0000000..06fa42f
--- /dev/null
+++ b/medcenterpersonal/legacy/README.md
@@ -0,0 +1,5 @@
+MedCbezFiz — медицинский центр без физиотерапевтического кабинета
+
+Готовый автономный лендинг.
+Файл index.html содержит встроенные стили, JavaScript и изображения.
+Для размещения загрузите index.html в нужную директорию сервера.
diff --git a/medcenterpersonal/legacy/index.html b/medcenterpersonal/legacy/index.html
new file mode 100644
index 0000000..1352571
--- /dev/null
+++ b/medcenterpersonal/legacy/index.html
@@ -0,0 +1,1041 @@
+
+
+
+
+
+
+Физиотерапия с нуля для медицинского центра | Экзо Групп
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Решение для медицинского центра без физиотерапии
+
Физиотерапия с нуля — готовое направление под поток вашего центра
+
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём клинические маршруты, обучаем команду.
+
+
+
от 12 м² стартовый формат одного кабинета
+
90 дней до контрольной точки пилота
+
35% к чистой прибыли превращаем кабинет физиотерапии в главный источник дохода клиники
+
+
Новый аппаратный этап внутри уже существующего маршрута пациента
+
+
+
+
+
+
+
+
Рыночный контекст
+
Спрос на реабилитацию растёт. Пациент уже находится внутри вашего центра
+
+
Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли.
+
+
+
+
+2,41
+млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация
+WHO / IHME
+
+
++63%
+рост глобальной потребности в реабилитации с 1990 года
+WHO / IHME
+
+
+1,0 → 1,4
+млн пациентов медицинской реабилитации в России, 2020–2024
+BusinesStat
+
+
+5,1%
+прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 2026–2033
+Grand View Research
+
+
+
+Исходная точка
+Маршрут пациента: как перестать отдавать прибыль конкурентам.
+
+
+
— Пока физиотерапии нет
+
+пациент ищет процедуры в другом месте;
+у врача нет аппаратного этапа комплексной программы;
+нет отдельной линейки курсов и пакетов;
+часть повторных визитов и выручки остаётся вне центра.
+
+
+
+
+ После запуска направления
+
+пациент проходит маршрут внутри одной клиники;
+назначение, процедуры и контроль связаны в курс;
+существующая база формирует стартовую загрузку;
+руководитель видит конверсию, завершение и экономику.
+
+
+
+
+
+
Рыночные показатели приведены по WHO/IHME, BusinesStat и Grand View Research. Они описывают рынок и потребность, но не являются прогнозом загрузки конкретного медицинского центра.
CAGR (Compound Annual Growth Rate) — среднегодовой темп роста: показатель, который показывает, каким был бы постоянный средний процент роста рынка за каждый год выбранного периода.
+
+
+
+
Сценарии выручки
+
Даже умеренная загрузка кабинета даёт понятную выручку
+
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и срок окупаемости.
+
+
+
30 курсов в месяц
0,81 млн ₽ осторожный сценарий
+
60 курсов в месяц
1,62 млн ₽ рабочая загрузка
+
100 курсов в месяц
2,70 млн ₽ сильная загрузка
+
+
Что превращает расчёт в окупаемость
+
Загрузка кабинета
Доля курсов
Повторные визиты
Средний чек
+
На финальном расчёте считаем CAPEX, ФОТ, площадь, аренду, расходники, налоги. Здесь показана рамка выручки, чтобы быстро понять порядок потенциала.
+
+
+
+
+
+
Готовое направление
+
Не закупка аппаратов, а работающая система физиотерапии
+
Каждый шаг нашей дорожной карты нужен для того, чтобы кабинет приносил реальные деньги, а не просто числился на балансе клиники. «Экзо Групп» комплексно решает задачи медицинского и операционного запуска.
+
+
+
+
+Удержать пациента Добавить недостающий аппаратный этап в маршрут лечения и реабилитации внутри центра.
+
+
+
+Создать новую выручку Запустить отдельную платную линейку процедур, курсов и комплексных программ.
+
+
+
+Усилить врачебный продукт Связать назначение врача, технологии, длительность курса и контроль динамики.
+
+
+
+Управлять показателями Измерять назначения, загрузку, завершение курса, повторные визиты и экономику.
+
+
+
+
+
Сначала выбирается клиническая задача. Затем — технология и формат кабинета.
+
+
Профили, для которых особенно актуален предварительный аудит:
+
Травматология и ортопедия Неврология Послеоперационная реабилитация Спортивная медицина Ревматология Флебология / лимфология Гинекология / Урология Эстетическая медицина
+
+
+
+
+
+
+
+
Курсовая модель
+
Новая услуга сразу запускается как маршрут, а не набор разовых процедур
+
Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель.
+
+
+
+
01 Нейрореабилитация Наше оборудование помогает пациентам восстановить двигательные функции, в т.ч. после инсульта, снизить проявления спастичности и парезов, улучшить баланс и координацию.
02 Травматология и ортопедия Устройства обеспечивают эффективную реабилитацию после переломов и эндопротезирования, способствуют снятию отёка и боли, повышают подвижность суставов и предупреждают развитие контрактур.
03 Послеоперационное восстановление Оборудование позволяет активизировать пациентов в ранние сроки, стимулирует регенерацию тканей и снижает риск послеоперационных осложнений.
04 Хронические боли и дегенеративные заболевания При остеохондрозе и артрозах оборудование помогает снизить болевой синдром и улучшить трофику и подвижность поражённых участков.
05 Детская реабилитация Специальные методики способствуют развитию моторных навыков у детей с ДЦП, укрепляют мышечный корсет и помогают освоить вертикальное положение.
06 Спортивная медицина Оборудование ускоряет восстановление спортсменов после травм и перегрузок, помогает грамотно вернуться к тренировкам и соревнованиям.
07 Паллиативная поддержка Устройства облегчают хроническую боль и помогают сохранить функциональную активность на максимально возможном уровне.
+
На мобильном — листайте программы
+
+
+Маршрут пациента
+Кабинет загружается текущим потоком, а не ожиданием случайных обращений
+
+
1 Первичный приём Цели, ограничения, противопоказания и исходные показатели.
+
2 Назначение курса Выбор маршрута и технологий в пределах документации.
+
3 Процедуры Выполнение по назначению, карте и утверждённому регламенту.
+
4 Контроль динамики Выбранные клинические показатели, переносимость и завершение курса.
+
5 Повторный приём Оценка результата, корректировка и следующий этап по показаниям.
+
+
+
+
+
+
+
+
+
+
Технологическое ядро
+
Состав под профиль центра и клинические маршруты
+
+
Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования.
+
+
+
+Системное усиление ЭкзоТерапия ОДА • неврология • дыхательная система • сосуды • органы малого таза • ЖКТ
Высокоинтенсивная импульсная магнитотерапия.
+
+
+Локальное ядро ЭкзоТекар 3 в 1 мышцы • суставы • связки • сухожилия • фасции • рубцы • посттравматическое восстановление
Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.
+
+
+Многофакторный модуль ЭкзоМагнит 3 в 1 суставы • позвоночник • мышцы • сухожилия • мягкие ткани • малый таз
Магнит высокой интенсивности + ударно-волновая терапия + инфракрасная терапия.
+
+
+Параллельный поток ЭкзоПресс отёчные программы • лимфоотток • снятие тяжести в ногах • сосуды • послеоперационная реабилитация
Компрессионная терапия + тепловая терапия + холодовая терапия.
+
+
+Поддерживающий контур ЭкзоВодород системное восстановление • антиоксидантная терапия • улучшение метаболизма • поддержка иммунитета • антистресс
Ингаляция молекулярным водородом
+
+
+Локальные программы ЭкзоЛазер В мягкотканные травмы • точечное воспаление • регенерация тканей • болевые синдромы • реабилитация • стимуляция микроциркуляции • противоотёчный эффект • лечение связок и сухожилий • ускорение метаболизма
Высокоинтенсивная лазерная терапия.
+
+
+
+
+
Нейромышечный модуль
+
ЭкзоИмпульс
+
электромагнитная стимуляция • нервно-мышечная система • устранение спазмов и зажимов • активация регенерации • противовоспалительный эффект • микроциркуляция и лимфодренаж • снятие глубоких мышечных болей • восстановление двигательной активности • реабилитация при неврологических патологиях
+
Фокусированная ударно-волновая терапия
+
+
+
+
На мобильном — листайте карточки в сторону
+
+
+
Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.
+
+
+
+
+
+
+
Пространство и запуск
Начать можно с одного кабинета и расширяться после подтверждения KPI
+
Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы.
+
+
+
Финальный метраж, зонирование и состав фиксируются только после аудита помещения и медицинской модели.
+
+
1–2 неделя. Сбор данных и предпроектный анализ потенциала Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы. Анализ действующей базы и потока : Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.Аудит помещений и эргономики : Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.Экспертиза лицензирования : Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.Оценка команды и выбор маршрутов : Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.3–5 неделя. Разработка бизнес-проекта и EXO-интеграции Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ. Создание концепции направления : Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.Подбор оптимального состава : Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.Проектирование планировки и сан-логистики : Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.Юридическое сопровождение : Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.Расчет экономики и плана пилота : Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.6–8 неделя. Бесшовная подготовка, оснащение и обучение команды Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса. Поставка и шеф-монтаж : Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.Обучение врачей с нуля : Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.Практика медсестер и администраторов : Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.Внедрение процедурных карт : Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.Настройка записи и расписания : Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.9–12 неделя. Запуск пилотного проекта и выход на плановую прибыль Тестовый запуск направления на вашей базе, контроль стандартов сервиса и получение первой коммерческой выручки. Запуск назначений и продаж : Перевод первых целевых пациентов из вашей действующей базы на новые EXO-курсы вместо разовых визитов.Контроль медицинских и бизнес-показателей : Сквозной мониторинг конверсии из приема в покупку курса, отслеживание динамики лечения пациентов по шкалам «до / после».Разбор отклонений и адаптация : Оперативная корректировка работы персонала, разбор сложных клинических случаев и донастройка скриптов администраторов нашими экспертами.Внедрение премиального сервиса : Контроль удовлетворенности пациентов для формирования лояльной базы и запуска сарафанного радио.
+
+
+
+
+
+
+
Почему Экзо Групп
+
Производство, клиническая практика и запуск — в одном цикле
+
Мы отвечаем за переход от оборудования к работающему направлению: технологии, методики, обучение, сервис, цифровой контроль и масштабирование.
+
+
+
+
+
Российское производство Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».
+
+
+
+
Собственная разработка и сервис Линейка высокоинтенсивных и восстановительных технологий внутри одного продуктового контура.
+
Сеть Экзо Клиник Оборудование, маршруты и организация работы проверяются на реальных медицинских процессах.
+
Полный контур внедрения Проект, оборудование, клиническая логика, обучение, сервис, продажи, KPI и масштабирование.
+
+
+
4 собственные клиники
+
70+ партнёрских клиник
+
60+ медицинских учреждений
+
2 млн+ проведённых процедур
+
+
Количественные показатели — по корпоративным материалам Экзо Групп; перед массовой внешней публикацией рекомендуется финальная сверка.
+
+
+
+
+
+
+
+
Следующий шаг
+
Запустим физиотерапию с нуля под ваш поток
+
Оставьте контакты — специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
+
+предварительный аудит базы и врачебных направлений;
+варианты пространства и состава технологий;
+план запуска на 90 дней и набор контрольных KPI.
+
+
+
+
+
+
+
+
+
+ИННОВАЦИОННАЯ ЭКОСИСТЕМА
+Экзо Групп — резидент ведущих технологических площадок
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/medcenterpersonal/package-lock.json b/medcenterpersonal/package-lock.json
new file mode 100644
index 0000000..895b766
--- /dev/null
+++ b/medcenterpersonal/package-lock.json
@@ -0,0 +1,3661 @@
+{
+ "name": "exo-medcenter-no-physio-landing",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "exo-medcenter-no-physio-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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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/medcenterpersonal/package.json b/medcenterpersonal/package.json
new file mode 100644
index 0000000..b448e7b
--- /dev/null
+++ b/medcenterpersonal/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "exo-medcenter-no-physio-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/medcenterpersonal/public/favicon.svg b/medcenterpersonal/public/favicon.svg
new file mode 100644
index 0000000..c4e6af4
--- /dev/null
+++ b/medcenterpersonal/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/medcenterpersonal/public/og-image.webp b/medcenterpersonal/public/og-image.webp
new file mode 100644
index 0000000..970529e
Binary files /dev/null and b/medcenterpersonal/public/og-image.webp differ
diff --git a/medcenterpersonal/scripts/amo-check.ts b/medcenterpersonal/scripts/amo-check.ts
new file mode 100644
index 0000000..f18ae99
--- /dev/null
+++ b/medcenterpersonal/scripts/amo-check.ts
@@ -0,0 +1,66 @@
+/**
+ * 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',
+ 'profile',
+ '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/medcenterpersonal/server/src/amocrm.ts b/medcenterpersonal/server/src/amocrm.ts
new file mode 100644
index 0000000..26f9edb
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/server/src/config.ts b/medcenterpersonal/server/src/config.ts
new file mode 100644
index 0000000..0bc47cc
--- /dev/null
+++ b/medcenterpersonal/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') ?? 3000,
+ isProduction: process.env.NODE_ENV === 'production',
+}
diff --git a/medcenterpersonal/server/src/index.ts b/medcenterpersonal/server/src/index.ts
new file mode 100644
index 0000000..0f42a89
--- /dev/null
+++ b/medcenterpersonal/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/medical-centers-no-physio', 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/medcenterpersonal/server/src/lead-mapper.ts b/medcenterpersonal/server/src/lead-mapper.ts
new file mode 100644
index 0000000..0db0d42
--- /dev/null
+++ b/medcenterpersonal/server/src/lead-mapper.ts
@@ -0,0 +1,149 @@
+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: 'Телефон',
+ profile: 'Основные направления центра',
+ 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: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
+ },
+ profile: {
+ codes: ['PROFILE', 'SPECIALITY'],
+ 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,
+ profile: payload.profile,
+ 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/medcenterpersonal/server/src/lead-service.ts b/medcenterpersonal/server/src/lead-service.ts
new file mode 100644
index 0000000..cce035a
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/server/src/rate-limit.ts b/medcenterpersonal/server/src/rate-limit.ts
new file mode 100644
index 0000000..9a01b13
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/shared/lead.ts b/medcenterpersonal/shared/lead.ts
new file mode 100644
index 0000000..8e180e3
--- /dev/null
+++ b/medcenterpersonal/shared/lead.ts
@@ -0,0 +1,43 @@
+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)),
+ company: optionalText(200),
+ /** «Основные направления центра» — one of the select's options, or free text. */
+ profile: 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/medcenterpersonal/src/App.tsx b/medcenterpersonal/src/App.tsx
new file mode 100644
index 0000000..81fac7b
--- /dev/null
+++ b/medcenterpersonal/src/App.tsx
@@ -0,0 +1,35 @@
+import { EquipmentSection } from './components/EquipmentSection'
+import { Hero } from './components/Hero'
+import { LaunchSection } from './components/LaunchSection'
+import { MarketSection } from './components/MarketSection'
+import { ProgramsSection } from './components/ProgramsSection'
+import { ProjectSection } from './components/ProjectSection'
+import { RequestSection } from './components/RequestSection'
+import { ResidencySection } from './components/ResidencySection'
+import { ScenariosSection } from './components/ScenariosSection'
+import { SiteFooter } from './components/SiteFooter'
+import { SiteHeader } from './components/SiteHeader'
+import { WhyExoSection } from './components/WhyExoSection'
+
+export default function App() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/medcenterpersonal/src/assets/images/device-hydrogen.webp b/medcenterpersonal/src/assets/images/device-hydrogen.webp
new file mode 100644
index 0000000..325f67d
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-hydrogen.webp differ
diff --git a/medcenterpersonal/src/assets/images/device-impulse.webp b/medcenterpersonal/src/assets/images/device-impulse.webp
new file mode 100644
index 0000000..4f94ea0
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-impulse.webp differ
diff --git a/medcenterpersonal/src/assets/images/device-laser.webp b/medcenterpersonal/src/assets/images/device-laser.webp
new file mode 100644
index 0000000..807c95d
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-laser.webp differ
diff --git a/medcenterpersonal/src/assets/images/device-magnet.webp b/medcenterpersonal/src/assets/images/device-magnet.webp
new file mode 100644
index 0000000..38e6d32
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-magnet.webp differ
diff --git a/medcenterpersonal/src/assets/images/device-press.webp b/medcenterpersonal/src/assets/images/device-press.webp
new file mode 100644
index 0000000..65d1c09
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-press.webp differ
diff --git a/medcenterpersonal/src/assets/images/device-tecar.webp b/medcenterpersonal/src/assets/images/device-tecar.webp
new file mode 100644
index 0000000..68df75f
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-tecar.webp differ
diff --git a/medcenterpersonal/src/assets/images/device-therapy.webp b/medcenterpersonal/src/assets/images/device-therapy.webp
new file mode 100644
index 0000000..b97396b
Binary files /dev/null and b/medcenterpersonal/src/assets/images/device-therapy.webp differ
diff --git a/medcenterpersonal/src/assets/images/exo-logo.png b/medcenterpersonal/src/assets/images/exo-logo.png
new file mode 100644
index 0000000..d1a7c79
Binary files /dev/null and b/medcenterpersonal/src/assets/images/exo-logo.png differ
diff --git a/medcenterpersonal/src/assets/images/hero-bg.webp b/medcenterpersonal/src/assets/images/hero-bg.webp
new file mode 100644
index 0000000..c4788d5
Binary files /dev/null and b/medcenterpersonal/src/assets/images/hero-bg.webp differ
diff --git a/medcenterpersonal/src/assets/images/production-site.webp b/medcenterpersonal/src/assets/images/production-site.webp
new file mode 100644
index 0000000..6344ffd
Binary files /dev/null and b/medcenterpersonal/src/assets/images/production-site.webp differ
diff --git a/medcenterpersonal/src/assets/images/resident-skolkovo.svg b/medcenterpersonal/src/assets/images/resident-skolkovo.svg
new file mode 100644
index 0000000..a808a1c
--- /dev/null
+++ b/medcenterpersonal/src/assets/images/resident-skolkovo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+ УЧАСТНИК
+ ПРОЕКТА «СКОЛКОВО»
+
\ No newline at end of file
diff --git a/medcenterpersonal/src/assets/images/resident-zhiguli.svg b/medcenterpersonal/src/assets/images/resident-zhiguli.svg
new file mode 100644
index 0000000..9fd42ee
--- /dev/null
+++ b/medcenterpersonal/src/assets/images/resident-zhiguli.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/medcenterpersonal/src/components/Button.tsx b/medcenterpersonal/src/components/Button.tsx
new file mode 100644
index 0000000..509d9d0
--- /dev/null
+++ b/medcenterpersonal/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-[790] 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'
+
+/**
+ * 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 tighter still on the narrowest phones. */
+export const headerSize = 'min-h-[44px] px-[17px] text-[13px] phone:px-[14px] phone:text-[12px]'
+
+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.28] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.44] 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]',
+}
+
+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/medcenterpersonal/src/components/EquipmentSection.tsx b/medcenterpersonal/src/components/EquipmentSection.tsx
new file mode 100644
index 0000000..a91bc13
--- /dev/null
+++ b/medcenterpersonal/src/components/EquipmentSection.tsx
@@ -0,0 +1,99 @@
+import { devices, equipmentNote } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { CheckIcon } from './Icons'
+import { Container, ScrollHint, Section, SectionHead } from './Layout'
+
+/**
+ * Product shots differ in framing, so the last stylesheet pass in the original
+ * sized two of them separately. Keyed by the device title.
+ */
+const mediaFit: Record = {
+ ЭкзоИмпульс: 'max-w-[88%] max-h-[88%] translate-y-[10px]',
+ 'ЭкзоЛазер В': 'max-w-[82%] max-h-[82%] max-[720px]:max-w-[86%] max-[720px]:max-h-[86%]',
+}
+
+const defaultFit = 'max-w-[86%] max-h-[86%] translate-y-[8px]'
+
+/** The laser shot is padded evenly instead of being nudged up off the base. */
+const mediaBox: Record = {
+ 'ЭкзоЛазер В': 'p-[20px]',
+}
+
+const defaultBox = 'px-[18px] pt-[18px] pb-[14px]'
+
+export function EquipmentSection() {
+ const track = useReveal()
+ const note = useReveal()
+
+ return (
+
+
+
+
+
+ {devices.map((device) => (
+
+
+
+
+
+
+
+ {device.kicker}
+
+
{device.title}
+
{device.indications}
+
+ {device.role}
+
+
+
+ ))}
+
+
+
+ На мобильном — листайте карточки в сторону
+
+
+ svg]:size-[20px] [&>svg]:shrink-0 [&>svg]:text-teal-bright',
+ note.revealClass,
+ )}
+ >
+
+ {equipmentNote}
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/FormField.tsx b/medcenterpersonal/src/components/FormField.tsx
new file mode 100644
index 0000000..3e096c2
--- /dev/null
+++ b/medcenterpersonal/src/components/FormField.tsx
@@ -0,0 +1,95 @@
+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-white/[0.08] text-white outline-none ' +
+ 'transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-white/[0.42] ' +
+ 'focus:border-teal focus:bg-white/[0.12] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.12)]'
+
+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 (
+
+
+
+ )
+}
+
+export function SelectField({
+ id,
+ label,
+ full,
+ placeholder,
+ options,
+ ...rest
+}: {
+ id: string
+ label: string
+ full?: boolean
+ placeholder: string
+ options: readonly string[]
+} & ComponentPropsWithRef<'select'>) {
+ return (
+
+
+ {/* Options inherit the dark control colours in some browsers, so the
+ palette is restated on them. */}
+
+ {placeholder}
+
+ {options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+ )
+}
+
+/** 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/medcenterpersonal/src/components/Hero.tsx b/medcenterpersonal/src/components/Hero.tsx
new file mode 100644
index 0000000..346ffd1
--- /dev/null
+++ b/medcenterpersonal/src/components/Hero.tsx
@@ -0,0 +1,72 @@
+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, Eyebrow } from './Layout'
+
+export function Hero() {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+
+
+
+
+
+
+
Решение для медицинского центра без физиотерапии
+
+
Физиотерапия с нуля — готовое направление под поток вашего центра
+
+
+ Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
+ клинические маршруты, обучаем команду.
+
+
+
+
+ Получить предварительный аудит
+
+
+
+
+
+ {heroFacts.map((fact) => (
+
+
+ {fact.value}
+
+ {fact.label}
+
+ ))}
+
+
+
+
+ Новый аппаратный этап внутри уже существующего маршрута пациента
+
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/Icons.tsx b/medcenterpersonal/src/components/Icons.tsx
new file mode 100644
index 0000000..1534920
--- /dev/null
+++ b/medcenterpersonal/src/components/Icons.tsx
@@ -0,0 +1,89 @@
+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 CheckIcon() {
+ return (
+
+
+
+ )
+}
+
+export function PhoneIcon() {
+ return (
+
+
+
+ )
+}
+
+/** Icons of the four promise cards, keyed by `promises[].icon`. */
+export const promiseIcons = {
+ retain: (
+
+
+
+ ),
+ revenue: (
+
+
+
+ ),
+ clinical: (
+
+
+
+ ),
+ metrics: (
+
+
+
+ ),
+}
+
+/** Icons of the «почему Экзо Групп» list, keyed by `whyItems[].icon`. */
+export const whyIcons = {
+ production: (
+
+
+
+ ),
+ clinics: (
+
+
+
+ ),
+ shield: (
+
+
+
+ ),
+}
diff --git a/medcenterpersonal/src/components/LaunchSection.tsx b/medcenterpersonal/src/components/LaunchSection.tsx
new file mode 100644
index 0000000..306c30a
--- /dev/null
+++ b/medcenterpersonal/src/components/LaunchSection.tsx
@@ -0,0 +1,77 @@
+import { formatNote, formats, timeline } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { Container, Section, SectionHead, SmallNote } from './Layout'
+
+export function LaunchSection() {
+ const grid = useReveal()
+ const steps = useReveal()
+
+ return (
+
+
+
+
+
+ {formats.map((format) => (
+
+
+
+ {format.area}
+
+ {format.title}
+ {format.text}
+
+ ))}
+
+
+ {formatNote}
+
+ {/* The timeline breaks into two columns at 900px, not at the shared
+ 760px step the rest of the page uses. */}
+
+ {timeline.map((step) => (
+
+
+ {step.week}
+
+ {step.title}
+ {step.lead}
+
+ {step.items.map((item) => (
+
+ {item.title}
+ {item.text ? `: ${item.text}` : null}
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/Layout.tsx b/medcenterpersonal/src/components/Layout.tsx
new file mode 100644
index 0000000..f14b921
--- /dev/null
+++ b/medcenterpersonal/src/components/Layout.tsx
@@ -0,0 +1,169 @@
+import type { ReactNode } from 'react'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+
+type Tone = 'light' | 'dark'
+
+/** `.container` — 1200px max, 16px gutters that grow to 24px from 760px up. */
+export function Container({ className, children }: { className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** `.eyebrow` — small uppercase kicker with the leading teal rule. */
+export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** `.lead` — the paragraph under a section heading. */
+export function SectionLead({
+ tone = 'light',
+ className,
+ children,
+}: {
+ tone?: Tone
+ className?: string
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** `.small-note` — the fine print under a section. */
+export function SmallNote({
+ 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 760px up, bottom-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 (
+
+ )
+}
+
+/** `.scroll-hint` — only meaningful while the card row still scrolls sideways. */
+export function ScrollHint({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/medcenterpersonal/src/components/LeadForm.tsx b/medcenterpersonal/src/components/LeadForm.tsx
new file mode 100644
index 0000000..8ff5e7f
--- /dev/null
+++ b/medcenterpersonal/src/components/LeadForm.tsx
@@ -0,0 +1,171 @@
+import { useState, type FormEvent } from 'react'
+import { profileOptions } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { submitLead } from '../lib/lead'
+import { formatRuPhone, isCompleteRuPhone } from '../lib/phone'
+import { Button } from './Button'
+import { Honeypot, SelectField, TextField, TextareaField } from './FormField'
+import { ArrowRightIcon } from './Icons'
+
+const EMPTY = {
+ name: '',
+ company: '',
+ phone: '',
+ email: '',
+ profile: '',
+ 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 (honeypot) return
+
+ if (!form.checkValidity()) {
+ form.reportValidity()
+ setStatus({ text: 'Проверьте обязательные поля формы.', tone: 'error' })
+ return
+ }
+
+ if (!isCompleteRuPhone(values.phone)) {
+ setStatus({ text: 'Укажите полный номер телефона.', tone: 'error' })
+ form.phone.focus()
+ 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/medcenterpersonal/src/components/MarketSection.tsx b/medcenterpersonal/src/components/MarketSection.tsx
new file mode 100644
index 0000000..e05282c
--- /dev/null
+++ b/medcenterpersonal/src/components/MarketSection.tsx
@@ -0,0 +1,121 @@
+import { marketNotes, marketStats, routeWith, routeWithout } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
+
+export function MarketSection() {
+ const stats = useReveal()
+ const gap = useReveal()
+
+ return (
+
+
+
+
+
+
+ {marketStats.map((stat) => (
+
+
+
+ {stat.value}
+
+ {stat.label}
+
+ {stat.source}
+
+
+ ))}
+
+
+
+
+
+
+ Исходная точка
+
+ Маршрут пациента: как перестать отдавать прибыль конкурентам.
+
+
+
+
+
+
+
+
+
+
+ {marketNotes[0]}
+ {marketNotes[1]}
+
+
+ )
+}
+
+function RouteColumn({
+ sign,
+ title,
+ items,
+ accent = false,
+}: {
+ sign: string
+ title: string
+ items: string[]
+ accent?: boolean
+}) {
+ return (
+
+
+
+ {sign}
+
+ {title}
+
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/ProgramsSection.tsx b/medcenterpersonal/src/components/ProgramsSection.tsx
new file mode 100644
index 0000000..3f690b3
--- /dev/null
+++ b/medcenterpersonal/src/components/ProgramsSection.tsx
@@ -0,0 +1,79 @@
+import { programs, routeSteps } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { Container, Eyebrow, ScrollHint, Section, SectionHead } from './Layout'
+
+export function ProgramsSection() {
+ const route = useReveal()
+
+ return (
+
+
+
+
+
+ {programs.map((program) => (
+
+ ))}
+
+
+ На мобильном — листайте программы
+
+
+
+
+ )
+}
+
+function ProgramCard({ num, title, text }: (typeof programs)[number]) {
+ const { ref, revealClass } = useReveal()
+
+ return (
+
+ {num}
+
+ {title}
+
+
+ {text}
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/ProjectSection.tsx b/medcenterpersonal/src/components/ProjectSection.tsx
new file mode 100644
index 0000000..e887ea1
--- /dev/null
+++ b/medcenterpersonal/src/components/ProjectSection.tsx
@@ -0,0 +1,75 @@
+import { profileChips, profileChipsNote, promises, systemStatement } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { promiseIcons } from './Icons'
+import { Container, Section, SectionHead, SmallNote } from './Layout'
+
+export function ProjectSection() {
+ const strip = useReveal()
+ const panel = useReveal()
+
+ return (
+
+
+
+ Каждый шаг нашей дорожной карты нужен для того, чтобы кабинет приносил реальные деньги, а не просто
+ числился на балансе клиники. «Экзо Групп» комплексно решает задачи медицинского и
+ операционного запуска.
+ >
+ }
+ />
+
+
+ {promises.map((promise) => (
+
+
+ {promiseIcons[promise.icon]}
+
+ {promise.title}
+ {promise.text}
+
+ ))}
+
+
+
+ {/* `.system-bottom` keeps its rule and spacing even though this page
+ dropped the card grid that used to sit above it. */}
+
+
+ {systemStatement}
+
+
+
{profileChipsNote}
+
+ {profileChips.map((chip) => (
+
+ {chip}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/RequestSection.tsx b/medcenterpersonal/src/components/RequestSection.tsx
new file mode 100644
index 0000000..321c351
--- /dev/null
+++ b/medcenterpersonal/src/components/RequestSection.tsx
@@ -0,0 +1,39 @@
+import { ctaChecks } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { CheckIcon } from './Icons'
+import { Container, Eyebrow, SectionLead } from './Layout'
+import { LeadForm } from './LeadForm'
+
+export function RequestSection() {
+ const copy = useReveal()
+
+ return (
+
+
+
+
Следующий шаг
+
Запустим физиотерапию с нуля под ваш поток
+
+ Оставьте контакты — специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего
+ подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
+
+
+
+ {ctaChecks.map((check) => (
+
+
+ {check}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/ResidencySection.tsx b/medcenterpersonal/src/components/ResidencySection.tsx
new file mode 100644
index 0000000..ca231ec
--- /dev/null
+++ b/medcenterpersonal/src/components/ResidencySection.tsx
@@ -0,0 +1,53 @@
+import skolkovo from '../assets/images/resident-skolkovo.svg'
+import zhiguli from '../assets/images/resident-zhiguli.svg'
+import { residencies } from '../data/content'
+
+const logos = [zhiguli, skolkovo]
+
+export function ResidencySection() {
+ return (
+
+
+
+
+
+
+ ИННОВАЦИОННАЯ ЭКОСИСТЕМА
+
+
+ Экзо Групп — резидент ведущих технологических площадок
+
+
+
+
+ {residencies.map((residency, index) => (
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/ScenariosSection.tsx b/medcenterpersonal/src/components/ScenariosSection.tsx
new file mode 100644
index 0000000..2351907
--- /dev/null
+++ b/medcenterpersonal/src/components/ScenariosSection.tsx
@@ -0,0 +1,81 @@
+import { revenueDrivers, revenueScenarios } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { ButtonLink } from './Button'
+import { ArrowRightIcon } from './Icons'
+
+/**
+ * `.fin-model` paints its own band and, unlike every other section, keeps the
+ * 32px gutter at all widths — `.fin-model .container` outranks the 760px rule
+ * that widens the shared container to 48px. Hence the inline wrapper here
+ * rather than /.
+ */
+export function ScenariosSection() {
+ const cta = useReveal()
+
+ return (
+
+
+
+ Сценарии выручки
+
+
+
+ Даже умеренная загрузка кабинета даёт понятную выручку
+
+
+ Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
+ срок окупаемости.
+
+
+
+
Формула расчёта
+
оплаченные курсы × средний чек ≈ 27 000 ₽
+
без учёта расходов
+
+
+
+ {revenueScenarios.map((scenario) => (
+
+
+
+ {scenario.courses}
+
+ курсов в месяц
+
+ {scenario.revenue}
+ {scenario.note}
+
+ ))}
+
+
+
Что превращает расчёт в окупаемость
+
+ {revenueDrivers.map((driver) => (
+
+ {driver}
+
+ ))}
+
+
+
+ На финальном расчёте считаем CAPEX, ФОТ, площадь, аренду, расходники, налоги. Здесь показана рамка выручки,
+ чтобы быстро понять порядок потенциала.
+
+
+
+
+ Как получить выручку?
+
+
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/components/SiteFooter.tsx b/medcenterpersonal/src/components/SiteFooter.tsx
new file mode 100644
index 0000000..2ecd8d5
--- /dev/null
+++ b/medcenterpersonal/src/components/SiteFooter.tsx
@@ -0,0 +1,28 @@
+import logo from '../assets/images/exo-logo.png'
+import { footerLegal } from '../data/content'
+import { Container } from './Layout'
+
+export function SiteFooter() {
+ return (
+
+ )
+}
diff --git a/medcenterpersonal/src/components/SiteHeader.tsx b/medcenterpersonal/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..677c4f5
--- /dev/null
+++ b/medcenterpersonal/src/components/SiteHeader.tsx
@@ -0,0 +1,58 @@
+import logo from '../assets/images/exo-logo.png'
+import { contacts, navLinks } from '../data/content'
+import { useScrolled } from '../hooks/useScrollState'
+import { cx } from '../lib/cx'
+import { ButtonLink, buttonClass, headerSize } from './Button'
+import { PhoneIcon } from './Icons'
+import { Container } from './Layout'
+
+/** Transparent over the hero; gains the blurred bar once the page scrolls. */
+export function SiteHeader() {
+ const scrolled = useScrolled()
+
+ return (
+
+ )
+}
diff --git a/medcenterpersonal/src/components/WhyExoSection.tsx b/medcenterpersonal/src/components/WhyExoSection.tsx
new file mode 100644
index 0000000..f0b0a93
--- /dev/null
+++ b/medcenterpersonal/src/components/WhyExoSection.tsx
@@ -0,0 +1,79 @@
+import productionSite from '../assets/images/production-site.webp'
+import { companyStats, companyStatsNote, photoCaption, whyItems } from '../data/content'
+import { useReveal } from '../hooks/useReveal'
+import { cx } from '../lib/cx'
+import { whyIcons } from './Icons'
+import { Container, Section, SectionHead, SmallNote } from './Layout'
+
+export function WhyExoSection() {
+ const photo = useReveal()
+ const list = useReveal()
+ const stats = useReveal()
+
+ return (
+
+
+
+
+
+
+
+
+ {photoCaption.title}
+ {photoCaption.text}
+
+
+
+
+
+ {whyItems.map((item) => (
+
+
+ {whyIcons[item.icon]}
+
+
+ {item.title}
+ {item.text}
+
+
+ ))}
+
+
+
+ {companyStats.map((stat) => (
+
+ {stat.value}
+ {stat.label}
+
+ ))}
+
+
+
{companyStatsNote}
+
+
+
+
+ )
+}
diff --git a/medcenterpersonal/src/data/content.ts b/medcenterpersonal/src/data/content.ts
new file mode 100644
index 0000000..8bdf1ed
--- /dev/null
+++ b/medcenterpersonal/src/data/content.ts
@@ -0,0 +1,457 @@
+import deviceHydrogen from '../assets/images/device-hydrogen.webp'
+import deviceImpulse from '../assets/images/device-impulse.webp'
+import deviceLaser from '../assets/images/device-laser.webp'
+import deviceMagnet from '../assets/images/device-magnet.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'
+
+export const contacts = {
+ phone: '+7 939 717-80-80',
+ phoneHref: 'tel:+79397178080',
+}
+
+export const navLinks = [
+ { href: '#market', label: 'Почему сейчас' },
+ { href: '#project', label: 'Что запускаем' },
+ { href: '#technology', label: 'Технологии' },
+ { href: '#launch', label: 'Запуск' },
+]
+
+/* -------------------------------------------------------------------------- */
+/* Первый экран */
+/* -------------------------------------------------------------------------- */
+
+export const heroFacts = [
+ { value: 'от 12 м²', label: 'стартовый формат одного кабинета' },
+ { value: '90 дней', label: 'до контрольной точки пилота' },
+ {
+ value: '35% к чистой прибыли',
+ label: 'превращаем кабинет физиотерапии в главный источник дохода клиники',
+ },
+]
+
+/* -------------------------------------------------------------------------- */
+/* Рыночный контекст */
+/* -------------------------------------------------------------------------- */
+
+export const marketStats = [
+ {
+ value: '2,41',
+ label: 'млрд людей в мире живут с состояниями, при которых может быть полезна реабилитация',
+ source: 'WHO / IHME',
+ },
+ { value: '+63%', label: 'рост глобальной потребности в реабилитации с 1990 года', source: 'WHO / IHME' },
+ {
+ value: '1,0 → 1,4',
+ label: 'млн пациентов медицинской реабилитации в России, 2020–2024',
+ source: 'BusinesStat',
+ },
+ {
+ value: '5,1%',
+ label: 'прогнозируемый CAGR рынка услуг медицинской реабилитации в РФ, 2026–2033',
+ source: 'Grand View Research',
+ },
+]
+
+/** «Пока физиотерапии нет» — левая колонка карточки маршрута. */
+export const routeWithout = [
+ 'пациент ищет процедуры в другом месте;',
+ 'у врача нет аппаратного этапа комплексной программы;',
+ 'нет отдельной линейки курсов и пакетов;',
+ 'часть повторных визитов и выручки остаётся вне центра.',
+]
+
+/** «После запуска направления» — правая, акцентная колонка. */
+export const routeWith = [
+ 'пациент проходит маршрут внутри одной клиники;',
+ 'назначение, процедуры и контроль связаны в курс;',
+ 'существующая база формирует стартовую загрузку;',
+ 'руководитель видит конверсию, завершение и экономику.',
+]
+
+export const marketNotes = [
+ 'Рыночные показатели приведены по WHO/IHME, BusinesStat и Grand View Research. Они описывают рынок и потребность, но не являются прогнозом загрузки конкретного медицинского центра.',
+ 'CAGR (Compound Annual Growth Rate) — среднегодовой темп роста: показатель, который показывает, каким был бы постоянный средний процент роста рынка за каждый год выбранного периода.',
+]
+
+/* -------------------------------------------------------------------------- */
+/* Сценарии выручки */
+/* -------------------------------------------------------------------------- */
+
+export const revenueScenarios = [
+ { courses: '30', revenue: '0,81 млн ₽', note: 'осторожный сценарий' },
+ { courses: '60', revenue: '1,62 млн ₽', note: 'рабочая загрузка' },
+ { courses: '100', revenue: '2,70 млн ₽', note: 'сильная загрузка' },
+]
+
+export const revenueDrivers = ['Загрузка кабинета', 'Доля курсов', 'Повторные визиты', 'Средний чек']
+
+/* -------------------------------------------------------------------------- */
+/* Готовое направление */
+/* -------------------------------------------------------------------------- */
+
+export const promises = [
+ {
+ icon: 'retain',
+ title: 'Удержать пациента',
+ text: 'Добавить недостающий аппаратный этап в маршрут лечения и реабилитации внутри центра.',
+ },
+ {
+ icon: 'revenue',
+ title: 'Создать новую выручку',
+ text: 'Запустить отдельную платную линейку процедур, курсов и комплексных программ.',
+ },
+ {
+ icon: 'clinical',
+ title: 'Усилить врачебный продукт',
+ text: 'Связать назначение врача, технологии, длительность курса и контроль динамики.',
+ },
+ {
+ icon: 'metrics',
+ title: 'Управлять показателями',
+ text: 'Измерять назначения, загрузку, завершение курса, повторные визиты и экономику.',
+ },
+] as const
+
+export const systemStatement =
+ 'Сначала выбирается клиническая задача. Затем — технология и формат кабинета.'
+
+export const profileChipsNote = 'Профили, для которых особенно актуален предварительный аудит:'
+
+export const profileChips = [
+ 'Травматология и ортопедия',
+ 'Неврология',
+ 'Послеоперационная реабилитация',
+ 'Спортивная медицина',
+ 'Ревматология',
+ 'Флебология / лимфология',
+ 'Гинекология / Урология',
+ 'Эстетическая медицина',
+]
+
+/* -------------------------------------------------------------------------- */
+/* Технологическое ядро */
+/* -------------------------------------------------------------------------- */
+
+export const devices = [
+ {
+ image: deviceTherapy,
+ alt: 'Аппарат ЭкзоТерапия',
+ kicker: 'Системное усиление',
+ title: 'ЭкзоТерапия',
+ indications: 'ОДА • неврология • дыхательная система • сосуды • органы малого таза • ЖКТ',
+ role: 'Высокоинтенсивная импульсная магнитотерапия.',
+ },
+ {
+ image: deviceTecar,
+ alt: 'Аппарат ЭкзоТекар 3 в 1',
+ kicker: 'Локальное ядро',
+ title: 'ЭкзоТекар 3 в 1',
+ indications: 'мышцы • суставы • связки • сухожилия • фасции • рубцы • посттравматическое восстановление',
+ role: 'Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.',
+ },
+ {
+ image: deviceMagnet,
+ alt: 'Аппарат ЭкзоМагнит 3 в 1',
+ kicker: 'Многофакторный модуль',
+ title: 'ЭкзоМагнит 3 в 1',
+ indications: 'суставы • позвоночник • мышцы • сухожилия • мягкие ткани • малый таз',
+ role: 'Магнит высокой интенсивности + ударно-волновая терапия + инфракрасная терапия.',
+ },
+ {
+ image: devicePress,
+ alt: 'Аппарат ЭкзоПресс',
+ kicker: 'Параллельный поток',
+ title: 'ЭкзоПресс',
+ indications:
+ 'отёчные программы • лимфоотток • снятие тяжести в ногах • сосуды • послеоперационная реабилитация',
+ role: 'Компрессионная терапия + тепловая терапия + холодовая терапия.',
+ },
+ {
+ image: deviceHydrogen,
+ alt: 'Аппарат ЭкзоВодород',
+ kicker: 'Поддерживающий контур',
+ title: 'ЭкзоВодород',
+ indications:
+ 'системное восстановление • антиоксидантная терапия • улучшение метаболизма • поддержка иммунитета • антистресс',
+ role: 'Ингаляция молекулярным водородом',
+ },
+ {
+ image: deviceLaser,
+ alt: 'Аппарат ЭкзоЛазер B',
+ kicker: 'Локальные программы',
+ title: 'ЭкзоЛазер В',
+ indications:
+ 'мягкотканные травмы • точечное воспаление • регенерация тканей • болевые синдромы • реабилитация • стимуляция микроциркуляции • противоотёчный эффект • лечение связок и сухожилий • ускорение метаболизма',
+ role: 'Высокоинтенсивная лазерная терапия.',
+ },
+ {
+ image: deviceImpulse,
+ alt: 'Аппарат ЭкзоИмпульс',
+ kicker: 'Нейромышечный модуль',
+ title: 'ЭкзоИмпульс',
+ indications:
+ 'электромагнитная стимуляция • нервно-мышечная система • устранение спазмов и зажимов • активация регенерации • противовоспалительный эффект • микроциркуляция и лимфодренаж • снятие глубоких мышечных болей • восстановление двигательной активности • реабилитация при неврологических патологиях',
+ role: 'Фокусированная ударно-волновая терапия',
+ },
+]
+
+export const equipmentNote =
+ 'Сначала выбирается клиническая задача и проверяется роль действующих методов. Затем подбирается недостающая технология. Аппараты применяются как инструмент врача; показания, противопоказания, квалификация персонала и формулировки сверяются с актуальной регистрационной документацией и лицензией клиники.'
+
+/* -------------------------------------------------------------------------- */
+/* Курсовая модель */
+/* -------------------------------------------------------------------------- */
+
+export const programs = [
+ {
+ num: '01',
+ title: 'Нейрореабилитация',
+ text: 'Наше оборудование помогает пациентам восстановить двигательные функции, в т.ч. после инсульта, снизить проявления спастичности и парезов, улучшить баланс и координацию.',
+ },
+ {
+ num: '02',
+ title: 'Травматология и ортопедия',
+ text: 'Устройства обеспечивают эффективную реабилитацию после переломов и эндопротезирования, способствуют снятию отёка и боли, повышают подвижность суставов и предупреждают развитие контрактур.',
+ },
+ {
+ num: '03',
+ title: 'Послеоперационное восстановление',
+ text: 'Оборудование позволяет активизировать пациентов в ранние сроки, стимулирует регенерацию тканей и снижает риск послеоперационных осложнений.',
+ },
+ {
+ num: '04',
+ title: 'Хронические боли и дегенеративные заболевания',
+ text: 'При остеохондрозе и артрозах оборудование помогает снизить болевой синдром и улучшить трофику и подвижность поражённых участков.',
+ },
+ {
+ num: '05',
+ title: 'Детская реабилитация',
+ text: 'Специальные методики способствуют развитию моторных навыков у детей с ДЦП, укрепляют мышечный корсет и помогают освоить вертикальное положение.',
+ },
+ {
+ num: '06',
+ title: 'Спортивная медицина',
+ text: 'Оборудование ускоряет восстановление спортсменов после травм и перегрузок, помогает грамотно вернуться к тренировкам и соревнованиям.',
+ },
+ {
+ num: '07',
+ title: 'Паллиативная поддержка',
+ text: 'Устройства облегчают хроническую боль и помогают сохранить функциональную активность на максимально возможном уровне.',
+ },
+]
+
+export const routeSteps = [
+ { title: 'Первичный приём', text: 'Цели, ограничения, противопоказания и исходные показатели.' },
+ { title: 'Назначение курса', text: 'Выбор маршрута и технологий в пределах документации.' },
+ { title: 'Процедуры', text: 'Выполнение по назначению, карте и утверждённому регламенту.' },
+ {
+ title: 'Контроль динамики',
+ text: 'Выбранные клинические показатели, переносимость и завершение курса.',
+ },
+ { title: 'Повторный приём', text: 'Оценка результата, корректировка и следующий этап по показаниям.' },
+]
+
+/* -------------------------------------------------------------------------- */
+/* Пространство и запуск */
+/* -------------------------------------------------------------------------- */
+
+export const formats = [
+ {
+ area: 'от 12 м²',
+ title: 'Один кабинет',
+ text: 'Стартовый формат под отдельный сценарий и ограниченную конфигурацию.',
+ },
+ {
+ area: 'от 35 м²',
+ title: 'Два кабинета',
+ text: 'Базовая инфраструктура с разделением активных и параллельных процедур.',
+ },
+ {
+ area: 'от 45 м²',
+ title: 'Три кабинета',
+ text: 'Расширенный формат для нескольких маршрутов и большей пропускной способности.',
+ },
+]
+
+export const formatNote =
+ 'Финальный метраж, зонирование и состав фиксируются только после аудита помещения и медицинской модели.'
+
+export const timeline = [
+ {
+ week: '1–2 неделя.',
+ title: 'Сбор данных и предпроектный анализ потенциала',
+ lead: 'Мы оцениваем ресурсы вашей клиники и рассчитываем экономическую выгоду от запуска нового направления без отрыва команды от текущей работы.',
+ items: [
+ {
+ title: 'Анализ действующей базы и потока',
+ text: 'Поиск скрытого спроса внутри вашей клиники — выявляем, сколько ваших пациентов уже сейчас нуждаются в физиотерапии и уходят за ней в другие центры.',
+ },
+ {
+ title: 'Аудит помещений и эргономики',
+ text: 'Оценка свободных или неэффективно используемых площадей клиники на соответствие техническим требованиям оборудования.',
+ },
+ {
+ title: 'Экспертиза лицензирования',
+ text: 'Анализ текущей медицинской лицензии, планирование и подготовка документов для быстрого открытия нового направления по стандартам Минздрава.',
+ },
+ {
+ title: 'Оценка команды и выбор маршрутов',
+ text: 'Аудит текущего штата врачей и определение приоритетных медицинских направлений — неврология, ортопедия, гинекология — для коммерческого старта.',
+ },
+ ],
+ },
+ {
+ week: '3–5 неделя.',
+ title: 'Разработка бизнес-проекта и EXO-интеграции',
+ lead: 'Мы создаем индивидуальную медицинскую и экономическую модель нового отделения под ключ.',
+ items: [
+ {
+ title: 'Создание концепции направления',
+ text: 'Определение позиционирования кабинета — превращаем физиотерапию из вспомогательной услуги в генератор комплексных курсов.',
+ },
+ {
+ title: 'Подбор оптимального состава',
+ text: 'Формирование индивидуального комплекта EXO-модулей, исходя из специфики вашей клиники, бюджета и портрета пациента.',
+ },
+ {
+ title: 'Проектирование планировки и сан-логистики',
+ text: 'Разработка эргономичного плана расстановки оборудования с учетом приватности пациентов, логистики чистых зон и требований СанПиН.',
+ },
+ {
+ title: 'Юридическое сопровождение',
+ text: 'Подготовка пакета документов, регламентов и чек-листов для прохождения проверок и получения лицензии.',
+ },
+ {
+ title: 'Расчет экономики и плана пилота',
+ text: 'Создание финансовой модели — стоимость запуска, маржинальность процедур, сроки окупаемости — и пошагового сценария тестового запуска.',
+ },
+ ],
+ },
+ {
+ week: '6–8 неделя.',
+ title: 'Бесшовная подготовка, оснащение и обучение команды',
+ lead: 'Мы полностью готовим инфраструктуру и персонал к запуску нового бизнес-процесса.',
+ items: [
+ {
+ title: 'Поставка и шеф-монтаж',
+ text: 'Доставка, установка и калибровка оборудования. Технические работы проводятся в удобное время, включая выходные и вечерние часы, без нарушения работы действующей клиники.',
+ },
+ {
+ title: 'Обучение врачей с нуля',
+ text: 'Практический курс по сочетанным методикам EXO-терапии, биомеханике и составлению курсовых программ.',
+ },
+ {
+ title: 'Практика медсестер и администраторов',
+ text: 'Инструктаж среднего медперсонала по безопасности и ведению параллельных процедур. Обучение администраторов скриптам продаж комплексных пакетов физиотерапии.',
+ },
+ {
+ title: 'Внедрение процедурных карт',
+ text: 'Интеграция готовых цифровых протоколов лечения в МИС клиники для автоматизации работы врачей.',
+ },
+ {
+ title: 'Настройка записи и расписания',
+ text: 'Создание бесконфликтной сетки расписания для максимальной загрузки кабинета без очередей и простоев площади.',
+ },
+ ],
+ },
+ {
+ week: '9–12 неделя.',
+ title: 'Запуск пилотного проекта и выход на плановую прибыль',
+ lead: 'Тестовый запуск направления на вашей базе, контроль стандартов сервиса и получение первой коммерческой выручки.',
+ items: [
+ {
+ title: 'Запуск назначений и продаж',
+ text: 'Перевод первых целевых пациентов из вашей действующей базы на новые EXO-курсы вместо разовых визитов.',
+ },
+ {
+ title: 'Контроль медицинских и бизнес-показателей',
+ text: 'Сквозной мониторинг конверсии из приема в покупку курса, отслеживание динамики лечения пациентов по шкалам «до / после».',
+ },
+ {
+ title: 'Разбор отклонений и адаптация',
+ text: 'Оперативная корректировка работы персонала, разбор сложных клинических случаев и донастройка скриптов администраторов нашими экспертами.',
+ },
+ {
+ title: 'Внедрение премиального сервиса',
+ text: 'Контроль удовлетворенности пациентов для формирования лояльной базы и запуска сарафанного радио.',
+ },
+ ],
+ },
+]
+
+/* -------------------------------------------------------------------------- */
+/* Почему Экзо Групп */
+/* -------------------------------------------------------------------------- */
+
+export const whyItems = [
+ {
+ icon: 'production',
+ title: 'Собственная разработка и сервис',
+ text: 'Линейка высокоинтенсивных и восстановительных технологий внутри одного продуктового контура.',
+ },
+ {
+ icon: 'clinics',
+ title: 'Сеть Экзо Клиник',
+ text: 'Оборудование, маршруты и организация работы проверяются на реальных медицинских процессах.',
+ },
+ {
+ icon: 'shield',
+ title: 'Полный контур внедрения',
+ text: 'Проект, оборудование, клиническая логика, обучение, сервис, продажи, KPI и масштабирование.',
+ },
+] as const
+
+export const photoCaption = {
+ title: 'Российское производство',
+ text: 'Производственная и сервисная инфраструктура в Тольятти, технопарк «Жигулёвская долина».',
+}
+
+export const companyStats = [
+ { value: '4', label: 'собственные клиники' },
+ { value: '70+', label: 'партнёрских клиник' },
+ { value: '60+', label: 'медицинских учреждений' },
+ { value: '2 млн+', label: 'проведённых процедур' },
+]
+
+export const companyStatsNote =
+ 'Количественные показатели — по корпоративным материалам Экзо Групп; перед массовой внешней публикацией рекомендуется финальная сверка.'
+
+/* -------------------------------------------------------------------------- */
+/* Заявка */
+/* -------------------------------------------------------------------------- */
+
+export const ctaChecks = [
+ 'предварительный аудит базы и врачебных направлений;',
+ 'варианты пространства и состава технологий;',
+ 'план запуска на 90 дней и набор контрольных KPI.',
+]
+
+export const profileOptions = [
+ 'Многопрофильный медицинский центр',
+ 'Ортопедия / неврология',
+ 'Реабилитация / ЛФК / спорт',
+ 'Флебология',
+ 'Женское / мужское здоровье',
+ 'Кардиология / пульмонология',
+ 'Онкореабилитация',
+ 'Другое',
+]
+
+/* -------------------------------------------------------------------------- */
+/* Подвал и резидентские статусы */
+/* -------------------------------------------------------------------------- */
+
+export const footerLegal =
+ 'Материалы страницы предназначены для B2B-обсуждения проекта. Конкретный состав, медицинские показания, противопоказания, лицензирование, квалификация персонала, сроки и финансовая модель определяются после аудита центра и сверки с актуальной документацией медицинских изделий.'
+
+export const residencies = [
+ {
+ href: 'https://isamara.ru/zhiguli-valley/',
+ label: 'Технопарк Жигулёвская долина',
+ width: 340,
+ height: 93,
+ },
+ { href: 'https://sk.ru/', label: 'Участник проекта Сколково', width: 330, height: 88 },
+]
diff --git a/medcenterpersonal/src/hooks/useReveal.ts b/medcenterpersonal/src/hooks/useReveal.ts
new file mode 100644
index 0000000..ce58032
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/src/hooks/useScrollState.ts b/medcenterpersonal/src/hooks/useScrollState.ts
new file mode 100644
index 0000000..7278cc5
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/src/index.css b/medcenterpersonal/src/index.css
new file mode 100644
index 0000000..1ffa2bb
--- /dev/null
+++ b/medcenterpersonal/src/index.css
@@ -0,0 +1,320 @@
+@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: #45e4d7;
+ --color-teal-deep: #00a99a;
+ --color-teal-ink: #008f84;
+ --color-ice: #eafbf8;
+
+ /* Surfaces & text */
+ --color-paper: #f5fafb;
+ --color-ink: #0a2540;
+ --color-muted: #60778b;
+ --color-footer: #041524;
+ /* The dark "сценарии выручки" band sits between navy and navy-deep. */
+ --color-scenario: #071f35;
+
+ /* Radii */
+ --radius-md: 18px;
+ --radius-lg: 25px;
+ --radius-xl: 34px;
+
+ /* Elevation */
+ --shadow-deep: 0 30px 90px rgb(4 30 48 / 0.16);
+ --shadow-soft: 0 18px 55px rgb(3 26 43 / 0.08);
+
+ /* Layout rhythm */
+ --container-page: 1200px;
+ --spacing-section: clamp(68px, 8vw, 108px);
+ --spacing-section-compact: clamp(58px, 6vw, 84px);
+
+ /* Breakpoints — the original used 600 / 760 / 980 */
+ --breakpoint-*: initial;
+ --breakpoint-sm: 600px;
+ --breakpoint-md: 760px;
+ --breakpoint-lg: 980px;
+
+ /* "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's `max-width` queries 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. Declared widest-first: Tailwind emits
+ variants in registration order, so a narrower one must come later to win. */
+@custom-variant stack (@media (max-width: 1050px));
+@custom-variant narrow (@media (max-width: 760px));
+@custom-variant compact (@media (max-width: 560px));
+@custom-variant phone (@media (max-width: 520px));
+@custom-variant tiny (@media (max-width: 430px));
+
+@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;
+ }
+
+ /* The fixed header is 78px tall; anchors must clear it. */
+ section[id] {
+ scroll-margin-top: 78px;
+ }
+
+ ::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: 940px;
+ font-size: clamp(42px, 7.7vw, 84px);
+ font-weight: 790;
+ line-height: 0.98;
+ letter-spacing: -0.055em;
+ }
+
+ h2 {
+ margin-bottom: 20px;
+ font-size: clamp(34px, 5vw, 58px);
+ font-weight: 760;
+ 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.65, 0.3, 1);
+ }
+
+ .reveal.is-visible {
+ opacity: 1;
+ transform: none;
+ }
+
+ /* Card rows that scroll horizontally on phones. Every section uses the same
+ mechanics and only differs in the card width and in the breakpoint where
+ the row becomes a plain grid — that part is applied per section with
+ `sm:grid-still` / `md:grid-still` and `auto-cols-*`. */
+ .scroll-cards {
+ display: grid;
+ grid-auto-flow: column;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ scroll-snap-type: inline mandatory;
+ scrollbar-width: none;
+ padding: 2px 16px 15px;
+ margin-inline: -16px;
+ }
+
+ .scroll-cards::-webkit-scrollbar {
+ display: none;
+ }
+
+ .scroll-cards > * {
+ scroll-snap-align: start;
+ }
+
+ /* Hero backdrop: two stacked scrims. The horizontal one is pushed further
+ right from 980px up, where the layout gains room. */
+ .hero-scrim {
+ background:
+ linear-gradient(
+ 90deg,
+ rgb(4 24 41 / 0.98) 0%,
+ rgb(5 28 47 / 0.92) 40%,
+ rgb(5 28 47 / 0.38) 76%,
+ rgb(5 28 47 / 0.14) 100%
+ ),
+ linear-gradient(0deg, rgb(3 20 34 / 0.78), transparent 48%);
+ }
+
+ @media (min-width: 980px) {
+ .hero-scrim {
+ background:
+ linear-gradient(
+ 90deg,
+ rgb(4 24 41 / 0.99) 0%,
+ rgb(5 28 47 / 0.93) 42%,
+ rgb(5 28 47 / 0.3) 74%,
+ rgb(5 28 47 / 0.1) 100%
+ ),
+ linear-gradient(0deg, rgb(3 20 34 / 0.75), transparent 50%);
+ }
+ }
+
+ /* The hero photo. The slight upscale hides the edges the transform would
+ otherwise leave visible; the focal point moves right once the copy column
+ has room, so the equipment stays clear of the text. */
+ .hero-photo {
+ background-position: 65% center;
+ background-size: cover;
+ transform: scale(1.015);
+ }
+
+ @media (min-width: 980px) {
+ .hero-photo {
+ background-position: 72% center;
+ }
+ }
+
+ /* Soft teal blooms. The three on this page differ in both opacity and stop
+ position, so they cannot collapse into one class. */
+ .hero-bloom {
+ background: radial-gradient(circle, rgb(0 196 180 / 0.24), transparent 64%);
+ }
+
+ .teal-bloom {
+ background: radial-gradient(circle, rgb(0 196 180 / 0.22), transparent 67%);
+ }
+
+ .teal-bloom-soft {
+ background: radial-gradient(circle, rgb(0 196 180 / 0.17), transparent 68%);
+ }
+
+ .gap-card-surface {
+ background: linear-gradient(145deg, var(--color-navy), #0c3650);
+ }
+
+ .system-panel-surface {
+ background: linear-gradient(145deg, #ffffff, #f2fbfa);
+ }
+
+ .equipment-card-surface {
+ background: linear-gradient(155deg, rgb(255 255 255 / 0.1), rgb(255 255 255 / 0.045));
+ }
+
+ .equipment-media-surface {
+ background: radial-gradient(circle at 50% 42%, rgb(0 196 180 / 0.15), rgb(3 22 38 / 0.16) 46%, rgb(3 22 38 / 0.64) 100%);
+ }
+
+ .cta-surface {
+ background:
+ radial-gradient(circle at 20% 20%, rgb(0 196 180 / 0.18), transparent 33%),
+ radial-gradient(circle at 90% 100%, rgb(37 117 151 / 0.26), transparent 39%),
+ var(--color-navy-deep);
+ }
+
+ .residency-surface {
+ background: linear-gradient(180deg, #071d31 0%, #041524 100%);
+ }
+
+ /* The arrow: two triangles drawn with gradients, as in the original
+ (no background-image utility can express this pair). */
+ .select-caret {
+ appearance: none;
+ background-image:
+ linear-gradient(45deg, transparent 50%, rgb(255 255 255 / 0.7) 50%),
+ linear-gradient(135deg, rgb(255 255 255 / 0.7) 50%, transparent 50%);
+ background-position:
+ calc(100% - 18px) 22px,
+ calc(100% - 13px) 22px;
+ background-size: 5px 5px;
+ background-repeat: no-repeat;
+ }
+}
+
+/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
+ breakpoint prefix, because sections switch over at different widths. */
+@utility grid-still {
+ grid-auto-flow: row;
+ grid-auto-columns: auto;
+ overflow: visible;
+ padding: 0;
+ margin-inline: 0;
+}
+
+@utility no-scrollbar {
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+/* Honeypot: must stay focusable-but-invisible, so `hidden` is not an option. */
+@utility honeypot {
+ position: absolute !important;
+ left: -9999px !important;
+ width: 1px !important;
+ height: 1px !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;
+ }
+}
diff --git a/medcenterpersonal/src/lib/cx.ts b/medcenterpersonal/src/lib/cx.ts
new file mode 100644
index 0000000..a2fc089
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/src/lib/lead.ts b/medcenterpersonal/src/lib/lead.ts
new file mode 100644
index 0000000..675bd7e
--- /dev/null
+++ b/medcenterpersonal/src/lib/lead.ts
@@ -0,0 +1,80 @@
+import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
+
+const ENDPOINT = '/api/leads/medical-centers-no-physio'
+const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft'
+
+const FALLBACK_ERROR =
+ 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.'
+
+/** 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 : FALLBACK_ERROR,
+ fields: body && !body.ok ? body.fields : undefined,
+ }
+ }
+
+ window.dataLayer = window.dataLayer ?? []
+ window.dataLayer.push({ event: 'medcenter_no_physio_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: FALLBACK_ERROR }
+ }
+}
+
+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/medcenterpersonal/src/lib/phone.ts b/medcenterpersonal/src/lib/phone.ts
new file mode 100644
index 0000000..686c419
--- /dev/null
+++ b/medcenterpersonal/src/lib/phone.ts
@@ -0,0 +1,22 @@
+/**
+ * Progressive `+7 900 000-00-00` mask, matching the original page's input
+ * handler. Returns the input untouched until the visitor has typed a digit, so
+ * that clearing the field is still possible.
+ */
+export function formatRuPhone(raw: string): string {
+ let digits = raw.replace(/\D/g, '').slice(0, 11)
+ if (!digits) return ''
+
+ if (digits[0] === '8') digits = `7${digits.slice(1)}`
+ if (digits[0] !== '7') digits = `7${digits}`
+
+ let value = '+7'
+ if (digits.length > 1) value += ` ${digits.slice(1, 4)}`
+ if (digits.length >= 5) value += ` ${digits.slice(4, 7)}`
+ if (digits.length >= 8) value += `-${digits.slice(7, 9)}`
+ if (digits.length >= 10) value += `-${digits.slice(9, 11)}`
+ return value
+}
+
+/** The original refused to submit before all eleven digits were entered. */
+export const isCompleteRuPhone = (value: string) => value.replace(/\D/g, '').length >= 11
diff --git a/medcenterpersonal/src/main.tsx b/medcenterpersonal/src/main.tsx
new file mode 100644
index 0000000..0fcd6ff
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/tsconfig.app.json b/medcenterpersonal/tsconfig.app.json
new file mode 100644
index 0000000..0387723
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/tsconfig.json b/medcenterpersonal/tsconfig.json
new file mode 100644
index 0000000..c3c5b3d
--- /dev/null
+++ b/medcenterpersonal/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.server.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/medcenterpersonal/tsconfig.node.json b/medcenterpersonal/tsconfig.node.json
new file mode 100644
index 0000000..b499a2f
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/tsconfig.server.json b/medcenterpersonal/tsconfig.server.json
new file mode 100644
index 0000000..1359696
--- /dev/null
+++ b/medcenterpersonal/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/medcenterpersonal/vite.config.ts b/medcenterpersonal/vite.config.ts
new file mode 100644
index 0000000..eb326c6
--- /dev/null
+++ b/medcenterpersonal/vite.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ // Set VITE_BASE_PATH=/medical-centers/no-physiotherapy/ when the site is
+ // mounted on a sub-path, so 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: {
+ // 5173/3000 and 5174/3001 belong to the fitnes/medcenter and hotel
+ // landings, so all of them can run side by side.
+ port: 5175,
+ proxy: {
+ '/api': { target: 'http://localhost:3002', changeOrigin: true },
+ },
+ },
+})