Monorepo skeleton, Prisma schema, core domain and services with tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
insert_final_newline = true
+49
View File
@@ -0,0 +1,49 @@
# ---- Общие ----
NODE_ENV=development
TZ=Asia/Yerevan
# ---- База и очереди (значения совпадают с infra/docker-compose.dev.yml) ----
DATABASE_URL=postgresql://barev:barev@localhost:5434/barev
REDIS_URL=redis://localhost:6380
# ---- Telegram ----
# Токены из BotFather. Без них API работает, а боты не стартуют.
CLIENT_BOT_TOKEN=
PRO_BOT_TOKEN=
# username ботов без @ — используется в deep-link на лендинге
CLIENT_BOT_USERNAME=barev_club_bot
PRO_BOT_USERNAME=barev_pro_bot
# Время жизни initData в секундах
TG_INIT_DATA_MAX_AGE=86400
# ---- Адреса приложений ----
LANDING_DOMAIN=localhost:3000
LANDING_URL=http://localhost:3000
API_URL=http://localhost:4000
CLIENT_MINIAPP_URL=http://localhost:5174
PRO_MINIAPP_URL=http://localhost:5175
ADMIN_URL=http://localhost:5173
API_PORT=4000
# ---- Админка ----
# Секрет для подписи сессий админки (любая длинная случайная строка)
ADMIN_SESSION_SECRET=change-me-to-a-long-random-string
# Первый владелец создаётся seed-скриптом
SEED_OWNER_EMAIL=owner@barev.local
SEED_OWNER_PASSWORD=barev-owner-123
# ---- Объектное хранилище (S3-совместимое) ----
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=barev-private
S3_ACCESS_KEY=barev
S3_SECRET_KEY=barev-secret
# ---- Мониторинг ----
SENTRY_DSN=
# ---- Только для локальной разработки ----
# Если токенов ботов ещё нет, initData для браузера подписывается этими значениями.
# В production не используется.
DEV_CLIENT_BOT_TOKEN=000000:dev-client-token
DEV_PRO_BOT_TOKEN=000000:dev-pro-token
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.next
.turbo
coverage
*.log
.env
.env.*
!.env.example
.DS_Store
packages/db/src/generated
+328
View File
@@ -0,0 +1,328 @@
# Barev Club — бизнес-требования для разработки (промпт для Claude Code)
> **Как использовать.** Положи этот файл в корень пустого репозитория как `docs/PRD.md` и напиши Claude Code:
> «Прочитай docs/PRD.md. Сначала предложи архитектуру, структуру репозитория и план по этапам, задай уточняющие вопросы из раздела 13. Код не пиши, пока я не подтвержу план. Потом реализуй Этап 1».
---
## 0. Роль и правила работы для Claude Code
Ты — ведущий full-stack разработчик. Строишь платформу Barev Club по этому документу.
- Работай **по этапам** (раздел 11). Не начинай следующий этап, пока текущий не принят по критериям из раздела 12.
- Перед кодом каждого этапа покажи план: какие модули, таблицы, эндпоинты, экраны.
- Если требование неоднозначно — спроси, не додумывай бизнес-логику. Технические решения принимай сам и коротко объясняй.
- Секреты только через `.env` (+ `.env.example` в репо). Никаких токенов в коде.
- Покрывай тестами бизнес-логику: статусы заказов, расчёт цены со скидкой, назначение исполнителя, промокоды.
- Добавь seed-данные (услуги, исполнители, партнёры, тестовые заказы), чтобы всё можно было показать без реальных пользователей.
- Все тексты интерфейса — через i18n, ни одной строки хардкодом.
---
## 1. Продукт и цель
**Barev Club** — клуб-сервис для эмигрантов в Ереване. Клиенты приходят через **агентства недвижимости** (QR/промокод в договоре аренды). Внутри одного Telegram-приложения клиент заказывает услуги по четырём направлениям и пользуется скидками партнёров.
**Слоган:** «Ереван — как дома. С первого дня.»
**Цель MVP:** клиент из агентства за 2 минуты открывает бота, выбирает язык, заказывает услугу и видит статус; админ назначает исполнителя; исполнитель проводит заказ по шагам.
**Ключевые метрики:**
- % лидов от агентства, активировавших бота;
- % активировавших, сделавших первый заказ;
- повторные заказы за 30 дней;
- конверсия в Barev+;
- лиды и выручка в разрезе агентств.
---
## 2. Направления и услуги (только эти, ничего не добавлять)
| Направление | Услуги | Формат |
|---|---|---|
| **Быт** | Клининг (обычная / генеральная / после ремонта), Ремонт (сантехника, электрика, мелкий ремонт), Трансфер (аэропорт ↔ адрес) | на адресе клиента / по городу |
| **Документы** | Соцкарта, Нотариальный перевод паспорта, ВНЖ | офлайн, с загрузкой документов |
| **Обучение и карьера** | Языки (армянский, английский; пакет уроков), Карьерный консультант (разбор резюме, план поиска работы) | онлайн / офлайн |
| **Скидки и подарки** | Скидки и подарки партнёров: кофейни, рестораны, другие | по клубной карте |
Цены — справочник в админке, на фронте показываются как «от N ֏». Конкретные цены и проценты скидок задаёт админ.
---
## 3. Роли
| Роль | Где работает | Что делает |
|---|---|---|
| **Клиент** | Telegram-бот + Mini App | Заказывает услуги, видит статусы, чат с исполнителем, оценивает, пользуется клубной картой, оформляет Barev+ |
| **Исполнитель** | **Отдельный Telegram-бот для исполнителей** + его Mini App | Подтипы: клинер, мастер, водитель, преподаватель, карьерный консультант. Видит заказы на день, ведёт по шагам, чек-лист, фотоотчёт, заработок. Исполнитель — либо частное лицо (фрилансер), либо сотрудник компании-партнёра |
| **Компания-партнёр (исполнитель)** | Тот же бот исполнителей, роль «диспетчер компании» | Клининговая компания, автопарк, языковая школа и т.п. Получает заказы, назначенные на компанию, и распределяет их между своими сотрудниками |
| **Админ / оператор** | Веб-админка | Канбан заказов, назначение исполнителей, справочники, клиенты, финансы, отчёты по агентствам |
| **Агентство** | Источник лидов (в MVP без кабинета) | Получает уникальный промокод/deep-link. Получает **% от оплаченных заказов привлечённых клиентов**; в отчётах видно его лиды, выручку и начисленное вознаграждение |
| **Партнёр** | В MVP без кабинета | Принимает клубную карту; проверяет её по коду на веб-странице проверки |
---
## 4. Каналы и поверхности
1. **Лендинг** (веб, адаптив) — за основу дизайн «Лендинг v2 · плотный».
2. **Клиентский Telegram-бот** — точка входа, онбординг, уведомления, ретрансляция чата.
3. **Клиентский Mini App** — основной интерфейс клиента.
4. **Бот исполнителей + его Mini App** — отдельный бот: назначения, принятие заказов, пошаговые статусы, чек-листы, фотоотчёты, заработок; роль «диспетчер компании» для компаний-партнёров.
5. **Веб-админка** — для операторов, desktop-first.
Домен лендинга у владельца уже есть (значение — в `.env`, `LANDING_DOMAIN`). Оба бота создаём в BotFather; токены — в `.env` (`CLIENT_BOT_TOKEN`, `PRO_BOT_TOKEN`).
Языки везде: **RU, EN, 中文 (ZH), Հայերեն (HY), ES**.
---
## 5. Функциональные требования
### 5.1 Лендинг (LND)
- **LND-1.** Секции, как в макете v2:
- шапка с языками и кнопкой «Открыть в Telegram»;
- hero: заголовок, подзаголовок, CTA и 3 шага;
- превью «Мои заявки»;
- плашки «5 языков» и «Без установки»;
- 4 блока направлений со списком услуг и ценами «от»;
- «Как это работает» в 4 шага;
- баннер агентства;
- тарифы Barev / Barev+;
- FAQ-аккордеон;
- подвал.
- **LND-2.** Переключатель 5 языков меняет все тексты; язык запоминается и передаётся в бота.
- **LND-3.** Все CTA — deep-link в бота `t.me/<bot>?start=<payload>`. Payload несёт язык, UTM и код агентства, если он есть в URL лендинга (`?a=<agency_code>`).
- **LND-4.** SEO-мета на каждом языке, OG-картинка, быстрая загрузка (Lighthouse ≥ 90 на мобильном).
- **LND-5.** Цены и тексты услуг подтягиваются из справочника, а не хардкодятся.
### 5.2 Онбординг в боте (ONB)
- **ONB-1.** `/start` с payload: определить язык и агентство, создать клиента и сохранить источник (агентство, UTM).
- **ONB-2.** Если язык не пришёл — выбор из 5 языков.
- **ONB-3.** Согласие на обработку персональных данных (ссылка на политику), без согласия дальше не пускать.
- **ONB-4.** Если клиент пришёл с кодом агентства — автоматически активировать подарочный месяц Barev+ и показать сообщение.
- **ONB-5.** Кнопка «Открыть приложение» запускает Mini App.
- **ONB-6.** Ручной ввод промокода агентства, если клиент пришёл без deep-link.
### 5.3 Каталог и заказ (ORD)
- **ORD-1.** Главная Mini App:
- приветствие;
- блок «Мои заявки» с активными заказами;
- быстрые кнопки;
- плитки 4 направлений;
- нижняя навигация: Главная / Услуги / + / Скидки / Профиль.
- **ORD-2.** Карточка услуги с параметрами, зависящими от услуги:
- клининг: тип уборки, примерная длительность;
- ремонт: категория и описание проблемы с фото;
- трансфер: рейс, количество пассажиров и багажа, адрес;
- языки: язык, уровень, онлайн/офлайн, пакет уроков;
- консультант: тема, загрузка резюме;
- документы: тип документа, загрузка сканов, для кого (сам / члены семьи).
- **ORD-3.** Выбор даты и времени из доступных слотов (для трансфера — время рейса).
- **ORD-4.** Расчёт цены: базовая цена по параметрам минус скидка Barev+ по направлению. Показывать строки «Стоимость», «Скидка Barev+», «Итого».
- **ORD-5.** Подтверждение заказа → статус «Новый» → уведомление клиенту и в админку.
- **ORD-6.** Бесплатная отмена и перенос — **не позднее чем за 24 часа** до начала услуги. Позже — отмена с удержанием (процент настраивается в админке по услугам) или только через оператора. Порог 24 ч тоже хранится в настройках, не в коде.
- **ORD-7.** Заказ «Повторить» из истории с теми же параметрами.
### 5.4 Статусы заказов (STS)
Своя последовательность статусов для каждой категории (state machine с проверкой допустимых переходов):
| Категория | Статусы |
|---|---|
| Клининг | Новый → Назначен → В пути → Уборка → Готово |
| Ремонт | Новый → Назначен → На месте → **Смета согласована клиентом** → Ремонт → Готово |
| Трансфер | Новый → Назначен → Водитель ждёт → В пути → Завершён |
| Языки | Записан → Урок N запланирован → Идёт урок → Урок завершён (прогресс «урок N из M») |
| Карьера | Новый → Консультант назначен → Резюме получено → Встреча → План отправлен |
| Документы | Новый → Документы проверяются → Подача → Готово (+ «Нужны доп. документы») |
Плюс «Отменён» из допустимых состояний. Каждый переход пишется в историю: кто, когда, комментарий. Клиент получает уведомление о каждом переходе.
- **STS-1.** В ремонте исполнитель вносит смету, клиент принимает или отклоняет её в Mini App. Без согласия переход в «Ремонт» невозможен.
### 5.5 Кабинет исполнителя (PRO)
- **PRO-1.** Шапка: имя, роль, статус смены (на смене / не на смене), заработок за день, рейтинг.
- **PRO-2.** Список заказов на сегодня; «Сейчас» — текущий заказ:
- адрес или «Онлайн»;
- язык клиента;
- заметка клиента.
- **PRO-3.** Пошаговые кнопки по роли:
- клинер: «Я на месте» → «Начать уборку» → чек-лист зон → «Завершить и отправить фото»;
- мастер: «Я на месте» → «Начать работу» → чек-лист «Диагностика / Смета / Ремонт / Фото» → «Закрыть»;
- водитель: «Я в аэропорту» → «Пассажир в машине» → «Багаж, адрес» → «Завершить поездку»;
- преподаватель: «Открыть урок» → «Начать» → «Повторение / Новая тема / ДЗ» → «Завершить урок»;
- консультант: «Открыть встречу» → «Начать» → «Резюме / План / Материалы» → «Завершить».
- **PRO-4.** Закрыть заказ нельзя, пока не отмечены все пункты чек-листа. Фото обязательно там, где требует чек-лист.
- **PRO-5.** Исполнитель принимает или отклоняет назначенный заказ (таймаут настраивается). Отказ возвращает заказ в «Новые».
- **PRO-7.** Заказ можно назначить на **частного исполнителя** или на **компанию-партнёра**. Во втором случае диспетчер компании в боте исполнителей назначает конкретного сотрудника; до этого заказ в админке помечен «Назначен на компанию».
- **PRO-8.** Выплаты: для фрилансеров ставка задаётся на исполнителе (фикс за услугу или % от заказа — выбирается в профиле исполнителя); для компаний — договорная ставка на уровне компании. Расчёт выплат — в разделе финансов админки.
- **PRO-6.** История заказов и заработок за период.
### 5.6 Веб-админка (ADM)
- **ADM-1.** Канбан заказов: Новые / Назначены / В работе / Готово, фильтр по направлению и услуге, по дате, поиск.
- **ADM-2.** Сводка: всего заказов, без исполнителя, выручка за день, свободные исполнители.
- **ADM-3.** Назначение исполнителя:
- система подсказывает свободного исполнителя нужной категории с учётом языка клиента, района и текущей загрузки;
- назначение в один клик или выбор вручную.
- **ADM-4.** Карточка заказа: параметры, история статусов, переписка, файлы, цена, оплата.
- **ADM-5.** Справочники:
- направления и услуги с параметрами и ценами;
- исполнители: роль, языки, районы, график, ставка;
- партнёры: категория, скидка, адрес, условия;
- агентства: код, условия вознаграждения;
- промокоды;
- тарифы Barev+ и скидки по направлениям.
- **ADM-6.** Клиенты: список, профиль, заказы, источник (агентство), статус Barev+.
- **ADM-7.** Финансы:
- выручка по услугам;
- выплаты исполнителям за период;
- вознаграждение агентствам;
- экспорт в CSV.
- **ADM-8.** Отчёт по агентствам: лиды → активировали → заказали → оплаченная выручка → **начисленное вознаграждение (% агентства от оплаченных заказов его клиентов)**. Процент задаётся на агентстве; срок, в течение которого клиент «принадлежит» агентству, — настройка (см. раздел 13). Выгрузка акта за месяц в CSV.
- **ADM-9.** Роли в админке: владелец (всё) и оператор (без финансов и настроек).
### 5.7 Чат и уведомления (MSG)
- **MSG-1.** Чат клиент ↔ исполнитель внутри заказа через бота-посредника; номера телефонов не раскрываются. Чат закрывается после завершения заказа + N часов.
- **MSG-2.** Push в Telegram:
- смена статуса;
- назначение;
- напоминание за 24 часа и за 1 час;
- просьба оценить.
- **MSG-3.** Все сообщения бота — на языке пользователя.
### 5.8 Скидки и клубная карта (CRD)
- **CRD-1.** Клубная карта в Mini App: имя, уровень (Barev / Barev+), QR и короткий код.
- **CRD-2.** Каталог партнёров: категория, скидка или подарок, адрес, условия.
- **CRD-3.** Публичная страница проверки карты для партнёра (`/check/<code>`): действительна ли карта, уровень. Персональных данных кроме имени не показывать.
- **CRD-4.** Логирование использований карты у партнёров для отчёта.
### 5.9 Членство Barev / Barev+ (SUB)
- **SUB-1.** Barev — бесплатно: каталог, клубная карта, скидки партнёров.
- **SUB-2.** Barev+ — ежемесячная подписка: скидка N% на Документы, Быт, Обучение и карьеру (проценты настраиваются по направлениям).
- **SUB-3.** Подарочный месяц по коду агентства, один раз на клиента. Напоминание за 3 дня до окончания.
- **SUB-4.** Экран выбора членства с переключением тарифов, как в макете.
### 5.10 Оценки (RTG)
- **RTG-1.** После завершения — оценка 1–5 и комментарий. Рейтинг исполнителя считается автоматически.
- **RTG-2.** Оценка ≤ 3 создаёт задачу для оператора.
### 5.11 Оплата (PAY)
- **PAY-1.** Способ оплаты на старте **ещё не выбран**. В MVP: заказ имеет поле «способ оплаты» (справочник, настраивается в админке) и статус оплаты; админ или исполнитель отмечает «оплачено» вручную. Никакой реальной интеграции с платёжкой в Этапах 1–2.
- **PAY-2.** Этап 3: онлайн-оплата и рекуррентная подписка через платёжного провайдера, работающего в Армении (выбор провайдера — открытый вопрос). Архитектуру сразу заложить через абстракцию `PaymentProvider` с заглушкой `ManualPaymentProvider`.
- **PAY-3.** Вознаграждение агентств и выплаты исполнителям считаются только по заказам со статусом оплаты «оплачено».
---
## 6. Нефункциональные требования
- **Безопасность:**
- проверка подписи Telegram `initData` на бэкенде для каждого запроса Mini App;
- RBAC по ролям;
- админка за логином с 2FA.
- **Персональные данные:**
- сканы паспортов и документов хранятся в приватном объектном хранилище с шифрованием;
- доступ по временным ссылкам только у оператора и назначенного исполнителя «Документы»;
- автоудаление через **90 дней** после завершения заказа (значение в настройках);
- логирование доступа к файлам.
- **i18n:** 5 языков. RU — исходный язык. EN, ZH, HY, ES — **машинный черновой перевод**, который делаешь ты; каждый ключ помечается статусом `needs_review`, пока носитель языка его не проверит. Сделай простой экран или экспорт (CSV/JSON) для проверки переводов носителями. Направление письма везде LTR. Шрифты с поддержкой кириллицы, армянского и китайского: фолбэк для армянского и китайского, если основной шрифт их не поддерживает.
- **Время:** часовой пояс Asia/Yerevan. Валюта — драм (֏).
- **Надёжность:**
- ежедневные бэкапы БД;
- логирование ошибок (Sentry или аналог);
- аудит-лог действий админов.
- **Производительность:** Mini App открывается быстрее 2 секунд на 4G.
- **Масштаб MVP:** до нескольких тысяч клиентов и сотен заказов в день, без микросервисов.
---
## 7. Модель данных (основные сущности)
`User` (telegram_id, роль, язык, имя, телефон?) · `Client` (источник/агентство, UTM, статус подписки) · `Provider` (роль-подтип, частный или сотрудник компании, языки, районы, график, тип ставки и ставка, рейтинг, на смене) · `ProviderCompany` (название, категории услуг, диспетчеры, договорная ставка) · `AgencyCommission` (агентство, заказ, % и сумма, период) · `Category` · `Service` (параметры как JSON-схема, базовая цена) · `Order` (клиент, услуга, параметры, слот, адрес/онлайн, цена, скидка, итог, статус, исполнитель, оплата) · `OrderStatusHistory` · `ChecklistItem` / `OrderChecklist` · `Estimate` (смета ремонта) · `OrderFile` · `Message` · `Rating` · `LessonPackage` (уроков всего / пройдено) · `Partner` · `CardUsage` · `Subscription` · `Agency` · `PromoCode` · `Payout` · `AdminUser` · `AuditLog`.
---
## 8. Рекомендуемый стек (можно предложить лучше и обосновать)
- **Монорепо** (pnpm/turborepo): `apps/landing`, `apps/miniapp-client`, `apps/miniapp-pro`, `apps/admin`, `apps/bot-client`, `apps/bot-pro`, `packages/db`, `packages/ui`, `packages/i18n`, `packages/core` (бизнес-логика, state machines).
- **Фронт:** Next.js + TypeScript (лендинг, админка), React + Vite (оба Mini App) с Telegram WebApp SDK.
- **Боты:** Node.js + grammY, два отдельных бота с общей бизнес-логикой из `packages/core`.
- **БД:** PostgreSQL + Prisma.
- **Хранилище файлов:** S3-совместимое, приватные бакеты.
- **Очереди и напоминания:** BullMQ + Redis или cron.
- **Деплой:** Docker. Один сервер на MVP, staging и prod окружения.
---
## 9. Дизайн-система (из макетов Barev Club)
- **Цвета:**
- фон `#FBF6EE`, текст `#1E1B1A`, вторичный текст `#5C5550`, акцент `#B8235A`, заголовки на розовом `#4A1028`;
- направления: Быт `#A8C38A`, Документы `#F9CDB0`, Обучение и карьера `#CFC8F5`, Скидки и подарки `#F7DA6E`;
- дополнительные: розовый `#F6C3D3`, голубой `#BCD0F2`.
- **Шрифты:** заголовки Rubik (700–800, приём «жирное + курсив»), текст Onest.
- **Форма:** крупные скругления (карточки 24–40px, кнопки-таблетки 999px), тёмная нижняя навигация-таблетка с белой «+» по центру, декоративные «цветы» (scalloped shapes).
- **Доступность:** контраст текста ≥ 4.5:1, зоны нажатия ≥ 44px.
- Сделать `packages/ui` с токенами и компонентами, чтобы лендинг, Mini App и админка выглядели единообразно.
---
## 10. Ключевые пользовательские сценарии
1. **Клиент из агентства:** QR в договоре → лендинг или сразу бот с кодом агентства → язык → согласие → подарочный Barev+ → заказ клининга со скидкой → статус «Назначен» → чат с клинером → «Готово» с фотоотчётом → оценка.
2. **Трансфер:** заказ с номером рейса → водитель назначен → «Водитель ждёт» → «В пути» → «Завершён».
3. **Ремонт со сметой:** мастер на месте → вносит смету → клиент согласует → ремонт → фото → закрытие.
4. **Документы:** клиент выбирает «Соцкарта» → загружает сканы → оператор проверяет → «Нужны доп. документы» или «Подача» → «Готово».
5. **Языки:** покупка пакета из M уроков → расписание → каждый урок закрывается преподавателем → прогресс «урок N из M».
6. **Скидка у партнёра:** клиент показывает карту → партнёр открывает страницу проверки → использование логируется.
7. **Админ:** новый заказ появляется в канбане → «Назначить: <подсказанный исполнитель>» → исполнитель принимает.
---
## 11. Этапы
- **Этап 1 (MVP-ядро):**
- монорепо, БД, i18n, `packages/ui`;
- лендинг v2;
- бот с онбордингом и кодами агентств;
- Mini App клиента: каталог, заказ, «Мои заявки», статусы;
- услуги **Клининг и Трансфер**;
- админка: канбан, назначение, справочники услуг и исполнителей;
- бот исполнителей и его Mini App для клинера и водителя (частные исполнители и диспетчер компании);
- уведомления; оплата отмечается вручную;
- машинный перевод на 5 языков со статусом `needs_review`.
- **Этап 2:**
- Ремонт со сметой, Документы с загрузкой файлов, Языки (пакеты уроков), Карьерный консультант;
- чат через бота;
- оценки;
- отчёт по агентствам;
- финансы и выплаты.
- **Этап 3:**
- клубная карта и партнёры со страницей проверки;
- Barev+ с подарочным месяцем, затем онлайн-оплата и рекуррентная подписка;
- аудит и автоудаление файлов.
---
## 12. Критерии приёмки (по этапу 1)
- [ ] Лендинг на 5 языках, все CTA ведут в бота с правильным payload (язык, агентство, UTM).
- [ ] `/start` с кодом агентства создаёт клиента с источником и активирует подарочный Barev+.
- [ ] Клиент оформляет клининг и трансфер, видит итог со скидкой, получает уведомление о каждом статусе.
- [ ] Недопустимый переход статуса отклоняется бэкендом (тесты на state machine для каждой категории).
- [ ] В админке новый заказ появляется в «Новых». Кнопка назначения предлагает свободного исполнителя нужной категории с учётом языка, назначение уходит исполнителю.
- [ ] Исполнитель не может закрыть заказ без всех пунктов чек-листа.
- [ ] Все строки интерфейса переведены (проверка: нет ключей без перевода).
- [ ] Запросы Mini App без валидного `initData` отклоняются.
- [ ] Seed-данные позволяют пройти все сценарии этапа на staging.
---
## 13. Принятые решения и открытые вопросы
**Уже решено владельцем:**
- Оплата на старте не выбрана → ручная отметка «оплачено», абстракция `PaymentProvider` (PAY-1, PAY-2).
- Исполнители смешанные: фрилансеры и компании-партнёры (PRO-7, PRO-8).
- Агентства получают % от оплаченных заказов привлечённых клиентов (ADM-8, PAY-3).
- Для исполнителей — отдельный Telegram-бот (раздел 4).
- Бесплатная отмена и перенос — за 24 часа до услуги (ORD-6).
- Сканы документов хранятся 90 дней после завершения заказа (раздел 6).
- Переводы: машинный черновик + проверка носителем (раздел 6).
- Домен есть, ботов ещё нет — создаём в BotFather (раздел 4).
**Ещё открыто (спроси перед стартом, а пока используй дефолты из скобок):**
1. Цены по услугам, % скидки Barev+ по направлениям и цена подписки (пока seed-значения-заглушки в админке).
2. Какой % получают агентства и как долго клиент «закреплён» за агентством (пока 10% и 12 месяцев, обе настройки — в админке).
3. Размер удержания при поздней отмене (пока 0%, настройка по услугам).
4. Платёжный провайдер для Этапа 3.
Binary file not shown.
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Barev Club — лендинг v2</title>
<script src="./vendor/react.js"></script>
<script src="./vendor/react-dom.js"></script>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400;1,500&amp;family=Onest:wght@400;500;600&amp;display=swap" rel="stylesheet">
<style>
body{margin:0;background:#FBF6EE}
a{color:#1E1B1A}a:hover{color:#B8235A}
</style>
</helmet>
<div style="font-family: 'Onest', system-ui, sans-serif; color: #1E1B1A; background: #FBF6EE; width: 100%">
<div style="max-width: 1240px; margin: 0 auto; padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px">
<header style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 8px 8px 20px; border-radius: 999px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 8px">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4A1028" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"></circle><path d="M8 12.5l2.6 2.6L16 9.5"></path></svg>
<div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 20px; color: #4A1028">Barev Club</div>
</div>
<nav style="display: flex; flex-wrap: wrap; gap: 20px; font-size: 15px; font-weight: 500">
<a href="#services" style="text-decoration: none">Услуги</a>
<a href="#how" style="text-decoration: none">Как это работает</a>
<a href="#plans" style="text-decoration: none">Членство</a>
<a href="#faq" style="text-decoration: none">Вопросы</a>
</nav>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 4px">
<sc-for list="{{langs}}" as="l" hint-placeholder-count="5">
<button onClick="{{l.pick}}" style="{{l.style}}">{{l.code}}</button>
</sc-for>
<a href="https://t.me/" style="margin-left: 6px; display: inline-flex; align-items: center; height: 44px; padding: 0 20px; border-radius: 999px; background: #1E1B1A; color: #FFFFFF; font-weight: 600; font-size: 14px; text-decoration: none">Открыть в Telegram</a>
</div>
</header>
<section style="display: flex; flex-wrap: wrap; gap: 12px">
<div style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; padding: 40px; border-radius: 36px; background: #F6C3D3; display: flex; flex-direction: column; gap: 22px">
<div style="align-self: flex-start; padding: 8px 14px; border-radius: 999px; background: #FFFFFF; font-size: 14px; font-weight: 600; color: #4A1028">Для тех, кто переехал в Ереван</div>
<h1 style="margin: 0; font-family: 'Rubik', sans-serif; font-weight: 800; font-size: clamp(40px, 5.4vw, 74px); line-height: 0.98; letter-spacing: -2px; color: #4A1028">{{title}}</h1>
<p style="margin: 0; font-size: 19px; line-height: 1.5; color: #4A1028; max-width: 560px">{{sub}}</p>
<div style="display: flex; flex-wrap: wrap; gap: 10px">
<a href="https://t.me/" style="display: inline-flex; align-items: center; gap: 10px; height: 56px; padding: 0 28px; border-radius: 999px; background: #1E1B1A; color: #FFFFFF; font-weight: 600; font-size: 16px; text-decoration: none">{{cta}}
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"></path></svg></a>
<a href="#services" style="display: inline-flex; align-items: center; height: 56px; padding: 0 24px; border-radius: 999px; background: #FFFFFF; font-weight: 600; font-size: 16px; text-decoration: none">Смотреть услуги</a>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: auto">
<div style="padding: 14px; border-radius: 22px; background: #FFFFFF"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 22px; color: #B8235A">1</div><div style="font-size: 14px; font-weight: 500">Открой бота в Telegram</div></div>
<div style="padding: 14px; border-radius: 22px; background: #FFFFFF"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 22px; color: #B8235A">2</div><div style="font-size: 14px; font-weight: 500">Выбери услугу и время</div></div>
<div style="padding: 14px; border-radius: 22px; background: #FFFFFF"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 22px; color: #B8235A">3</div><div style="font-size: 14px; font-weight: 500">Следи за статусом в приложении</div></div>
</div>
</div>
<div style="flex: 1 1 380px; min-width: 0; display: flex; flex-direction: column; gap: 12px">
<div style="padding: 20px; border-radius: 36px; background: #FFFFFF; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0 4px 6px"><div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 20px">Мои заявки</div><div style="font-size: 13px; color: #5C5550">в приложении</div></div>
<div style="padding: 12px 14px; border-radius: 20px; background: #BCD0F2; color: #12264D; display: flex; align-items: center; gap: 10px"><div style="flex: 1"><div style="font-weight: 600; font-size: 14px">Трансфер · аэропорт → дом</div><div style="font-size: 12px">Сегодня 12:30</div></div><span style="padding: 4px 10px; border-radius: 999px; background: #FFFFFF; font-size: 12px; font-weight: 600">Ждёт вас</span></div>
<div style="padding: 12px 14px; border-radius: 20px; background: #A8C38A; color: #1F3311; display: flex; align-items: center; gap: 10px"><div style="flex: 1"><div style="font-weight: 600; font-size: 14px">Клининг · генеральная</div><div style="font-size: 12px">Сб 16:00</div></div><span style="padding: 4px 10px; border-radius: 999px; background: #FFFFFF; font-size: 12px; font-weight: 600">Назначен</span></div>
<div style="padding: 12px 14px; border-radius: 20px; background: #F9CDB0; color: #4D200A; display: flex; align-items: center; gap: 10px"><div style="flex: 1"><div style="font-weight: 600; font-size: 14px">Соцкарта</div><div style="font-size: 12px">Документы проверяются</div></div><span style="padding: 4px 10px; border-radius: 999px; background: #FFFFFF; font-size: 12px; font-weight: 600">В работе</span></div>
<div style="padding: 12px 14px; border-radius: 20px; background: #CFC8F5; color: #2B1F5C; display: flex; align-items: center; gap: 10px"><div style="flex: 1"><div style="font-weight: 600; font-size: 14px">Армянский A1 · урок 3</div><div style="font-size: 12px">Сегодня 15:00 · онлайн</div></div><span style="padding: 4px 10px; border-radius: 999px; background: #FFFFFF; font-size: 12px; font-weight: 600">Сегодня</span></div>
</div>
<div style="display: flex; gap: 12px">
<div style="flex: 1; padding: 18px; border-radius: 30px; background: #F7DA6E; display: flex; align-items: center; gap: 12px">
<div style="position: relative; width: 72px; height: 72px; flex: none; transform: rotate(-10deg)">
<svg aria-hidden="true" viewBox="0 0 100 100" width="72" height="72" style="position: absolute; inset: 0"><g fill="#B8235A"><circle cx="50" cy="50" r="34"></circle><circle cx="82" cy="50" r="16"></circle><circle cx="75.9" cy="68.8" r="16"></circle><circle cx="59.9" cy="80.4" r="16"></circle><circle cx="40.1" cy="80.4" r="16"></circle><circle cx="24.1" cy="68.8" r="16"></circle><circle cx="18" cy="50" r="16"></circle><circle cx="24.1" cy="31.2" r="16"></circle><circle cx="40.1" cy="19.6" r="16"></circle><circle cx="59.9" cy="19.6" r="16"></circle><circle cx="75.9" cy="31.2" r="16"></circle></g></svg>
<div style="position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #FFFFFF; font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 26px">5</div>
</div>
<div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 17px; line-height: 1.15; color: #4A1028">языков:<br>RU EN 中文 HY ES</div>
</div>
<div style="flex: 1; padding: 18px; border-radius: 30px; background: #CFC8F5; display: flex; flex-direction: column; justify-content: center; gap: 4px; color: #2B1F5C">
<div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 18px; line-height: 1.15">Без установки</div>
<div style="font-size: 13px">Всё внутри Telegram</div>
</div>
</div>
</div>
</section>
<section id="services" style="display: flex; flex-wrap: wrap; gap: 12px">
<div style="flex: 1 1 270px; min-width: 0; box-sizing: border-box; padding: 24px; border-radius: 32px; background: #A8C38A; color: #1F3311; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; justify-content: space-between; align-items: center"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 26px">Быт</div>
<svg aria-hidden="true" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11l8-7 8 7v9H4z"></path></svg></div>
<div style="display: flex; flex-direction: column; gap: 6px">
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Клининг</span><span>от [ЦЕНА] ֏</span></div>
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Ремонт</span><span>от [ЦЕНА] ֏</span></div>
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Трансфер</span><span>от [ЦЕНА] ֏</span></div>
</div>
<div style="font-size: 14px">Проверенные исполнители, статус заказа в приложении.</div>
<a href="https://t.me/" style="margin-top: auto; font-weight: 700; color: #1F3311">Заказать →</a>
</div>
<div style="flex: 1 1 270px; min-width: 0; box-sizing: border-box; padding: 24px; border-radius: 32px; background: #F9CDB0; color: #4D200A; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; justify-content: space-between; align-items: center"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 26px">Документы</div>
<svg aria-hidden="true" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3h7l5 5v13H7zM14 3v5h5M10 13h6M10 17h6"></path></svg></div>
<div style="display: flex; flex-direction: column; gap: 6px">
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Соцкарта</span><span>от [ЦЕНА] ֏</span></div>
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Перевод паспорта</span><span>от [ЦЕНА] ֏</span></div>
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">ВНЖ</span><span>от [ЦЕНА] ֏</span></div>
</div>
<div style="font-size: 14px">Нотариальный перевод и сопровождение до готового документа.</div>
<a href="https://t.me/" style="margin-top: auto; font-weight: 700; color: #4D200A">Оформить →</a>
</div>
<div style="flex: 1 1 270px; min-width: 0; box-sizing: border-box; padding: 24px; border-radius: 32px; background: #CFC8F5; color: #2B1F5C; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; justify-content: space-between; align-items: center"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 26px; line-height: 1">Обучение и карьера</div>
<svg aria-hidden="true" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: none"><path d="M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z"></path></svg></div>
<div style="display: flex; flex-direction: column; gap: 6px">
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Армянский</span><span>от [ЦЕНА] ֏</span></div>
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Английский</span><span>от [ЦЕНА] ֏</span></div>
<div style="height: 46px; padding: 0 16px; border-radius: 999px; background: #FFFFFF; display: flex; align-items: center; justify-content: space-between; font-size: 15px"><span style="font-weight: 600">Карьерный консультант</span><span>от [ЦЕНА] ֏</span></div>
</div>
<div style="font-size: 14px">Онлайн и офлайн, в удобное время.</div>
<a href="https://t.me/" style="margin-top: auto; font-weight: 700; color: #2B1F5C">Записаться →</a>
</div>
<div style="flex: 1 1 270px; min-width: 0; box-sizing: border-box; padding: 24px; border-radius: 32px; background: #F7DA6E; color: #4A1028; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; justify-content: space-between; align-items: center"><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 26px; line-height: 1">Скидки и подарки</div>
<svg aria-hidden="true" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: none"><path d="M4 8h16v4H4zM5 12h14v8H5zM12 8v12M12 8c-2-3-6-3-6 0M12 8c2-3 6-3 6 0"></path></svg></div>
<div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 64px; line-height: 1; letter-spacing: -2px">до −[N]%</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px">
<span style="padding: 8px 14px; border-radius: 999px; background: #FFFFFF; font-size: 14px; font-weight: 500">Кофейни</span>
<span style="padding: 8px 14px; border-radius: 999px; background: #FFFFFF; font-size: 14px; font-weight: 500">Рестораны</span>
<span style="padding: 8px 14px; border-radius: 999px; background: #FFFFFF; font-size: 14px; font-weight: 500">Подарки партнёров</span>
</div>
<div style="font-size: 14px">Покажи клубную карту в Telegram на кассе.</div>
<a href="https://t.me/" style="margin-top: auto; font-weight: 700; color: #4A1028">Смотреть партнёров →</a>
</div>
</section>
<section id="how" style="display: flex; flex-wrap: wrap; gap: 12px">
<div style="flex: 999 1 640px; min-width: 0; box-sizing: border-box; padding: 28px; border-radius: 36px; background: #1E1B1A; color: #FFFFFF; display: flex; flex-direction: column; gap: 18px">
<h2 style="margin: 0; font-family: 'Rubik', sans-serif; font-size: 34px; letter-spacing: -1px"><span style="font-weight: 700">Как это</span> <span style="font-weight: 400; font-style: italic">работает</span></h2>
<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px">
<div style="padding: 16px; border-radius: 24px; background: #2E2A28; display: flex; flex-direction: column; gap: 8px"><div style="width: 36px; height: 36px; border-radius: 50%; background: #F6C3D3; color: #4A1028; display: flex; align-items: center; justify-content: center; font-weight: 700">1</div><div style="font-weight: 600">Отсканируй QR</div><div style="font-size: 13px; color: #CFC8C2">Из договора аренды или с сайта</div></div>
<div style="padding: 16px; border-radius: 24px; background: #2E2A28; display: flex; flex-direction: column; gap: 8px"><div style="width: 36px; height: 36px; border-radius: 50%; background: #F7DA6E; color: #4A1028; display: flex; align-items: center; justify-content: center; font-weight: 700">2</div><div style="font-weight: 600">Выбери язык</div><div style="font-size: 13px; color: #CFC8C2">Пять языков на выбор</div></div>
<div style="padding: 16px; border-radius: 24px; background: #2E2A28; display: flex; flex-direction: column; gap: 8px"><div style="width: 36px; height: 36px; border-radius: 50%; background: #A8C38A; color: #1F3311; display: flex; align-items: center; justify-content: center; font-weight: 700">3</div><div style="font-weight: 600">Закажи услугу</div><div style="font-size: 13px; color: #CFC8C2">Время, адрес, оплата</div></div>
<div style="padding: 16px; border-radius: 24px; background: #2E2A28; display: flex; flex-direction: column; gap: 8px"><div style="width: 36px; height: 36px; border-radius: 50%; background: #BCD0F2; color: #12264D; display: flex; align-items: center; justify-content: center; font-weight: 700">4</div><div style="font-weight: 600">Следи за статусом</div><div style="font-size: 13px; color: #CFC8C2">Чат с исполнителем внутри</div></div>
</div>
</div>
<div style="flex: 1 1 340px; min-width: 0; box-sizing: border-box; padding: 28px; border-radius: 36px; background: #A8C38A; display: flex; align-items: center; gap: 16px">
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px">
<div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 26px; line-height: 1.05; color: #1F3311">Сняли квартиру через [АГЕНТСТВО]?</div>
<div style="font-size: 15px; color: #1F3311">Код на месяц Barev+ уже в договоре.</div>
</div>
<div style="width: 120px; height: 110px; flex: none; box-sizing: border-box; padding: 14px; background: #F7DA6E; border-radius: 6px; transform: rotate(6deg); display: flex; flex-direction: column; justify-content: space-between">
<div style="font-family: 'Rubik', sans-serif; font-style: italic; font-size: 20px; font-weight: 500; color: #4A1028">Подарок!</div>
<div style="font-size: 12px; color: #4A1028">1 месяц Barev+</div>
</div>
</div>
</section>
<section id="plans" style="display: flex; flex-wrap: wrap; gap: 12px; align-items: stretch">
<div style="flex: 1 1 300px; min-width: 0; box-sizing: border-box; padding: 28px; border-radius: 36px; background: #FFFFFF; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; justify-content: space-between; align-items: baseline"><div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 28px">Barev</div><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 32px">0 ֏</div></div>
<div style="display: flex; flex-direction: column; gap: 8px; font-size: 15px">
<div style="display: flex; gap: 8px; align-items: center"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F7A52" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"></path></svg>Скидки и подарки партнёров</div>
<div style="display: flex; gap: 8px; align-items: center"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F7A52" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"></path></svg>Все услуги по обычной цене</div>
<div style="display: flex; gap: 8px; align-items: center"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F7A52" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"></path></svg>Статусы заявок и чат</div>
</div>
<a href="https://t.me/" style="margin-top: auto; display: flex; align-items: center; justify-content: center; height: 52px; border-radius: 999px; border: 2px solid #1E1B1A; font-weight: 600; text-decoration: none">Вступить бесплатно</a>
</div>
<div style="flex: 1 1 300px; min-width: 0; box-sizing: border-box; padding: 28px; border-radius: 36px; background: #CFC8F5; color: #2B1F5C; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; justify-content: space-between; align-items: baseline"><div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 28px">Barev+</div><div style="font-family: 'Rubik', sans-serif; font-weight: 800; font-size: 24px">[ЦЕНА] ֏/мес</div></div>
<div style="display: flex; flex-direction: column; gap: 8px; font-size: 15px">
<div style="display: flex; gap: 8px; align-items: center"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B8235A" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"></path></svg>Всё из Barev</div>
<div style="display: flex; gap: 8px; align-items: center"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B8235A" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"></path></svg>−[N]% на документы и быт</div>
<div style="display: flex; gap: 8px; align-items: center"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B8235A" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"></path></svg>−[N]% на обучение и карьеру</div>
</div>
<a href="https://t.me/" style="margin-top: auto; display: flex; align-items: center; justify-content: center; height: 52px; border-radius: 999px; background: #1E1B1A; color: #FFFFFF; font-weight: 600; text-decoration: none">Попробовать Barev+</a>
</div>
<div id="faq" style="flex: 1 1 340px; min-width: 0; box-sizing: border-box; padding: 28px; border-radius: 36px; background: #F6C3D3; color: #4A1028; display: flex; flex-direction: column; gap: 8px">
<div style="font-family: 'Rubik', sans-serif; font-weight: 700; font-size: 28px; margin-bottom: 4px">Вопросы</div>
<sc-for list="{{faq}}" as="q" hint-placeholder-count="4">
<div style="border-radius: 20px; background: #FFFFFF; overflow: hidden">
<button onClick="{{q.toggle}}" style="width: 100%; min-height: 48px; padding: 12px 16px; border: none; background: transparent; font-family: inherit; font-size: 15px; font-weight: 600; color: #1E1B1A; text-align: left; display: flex; align-items: center; justify-content: space-between; gap: 10px; cursor: pointer"><span>{{q.q}}</span><span aria-hidden="true">{{q.sign}}</span></button>
<sc-if value="{{q.open}}" hint-placeholder-val="{{false}}">
<div style="padding: 0 16px 14px; font-size: 14px; line-height: 1.45; color: #5C5550">{{q.a}}</div>
</sc-if>
</div>
</sc-for>
</div>
</section>
<footer style="display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; align-items: center; padding: 18px 24px; border-radius: 999px; background: #FFFFFF; font-size: 14px; color: #5C5550">
<div style="font-family: 'Rubik', sans-serif; font-weight: 700; color: #4A1028">Barev Club · Ереван</div>
<div style="display: flex; flex-wrap: wrap; gap: 20px"><a href="https://t.me/">Стать партнёром</a><a href="mailto:[EMAIL]">[EMAIL]</a><a href="#">Политика конфиденциальности</a></div>
</footer>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":2400}}'>
class Component extends DCLogic {
constructor(props) {
super(props);
this.state = { lang: 'RU', open: 0 };
}
renderVals() {
const copy = {
RU: { t: 'Ереван — как дома. С первого дня.', s: 'Быт, документы, обучение и скидки партнёров — в одном клубе в Telegram.', c: 'Вступить бесплатно' },
EN: { t: 'Yerevan feels like home. From day one.', s: 'Home services, documents, learning and partner discounts — one club in Telegram.', c: 'Join for free' },
'中文': { t: '在埃里温,从第一天起就像在家。', s: '生活服务、证件、学习和合作伙伴优惠——尽在 Telegram 俱乐部。', c: '免费加入' },
'ՀԱՅ': { t: 'Երևանը՝ ինչպես տանը։ Առաջին օրվանից։', s: 'Կենցաղ, փաստաթղթեր, ուսում և գործընկերների զեղչեր՝ մեկ ակումբում Telegram-ում։', c: 'Միանալ անվճար' },
ES: { t: 'Ereván como en casa. Desde el primer día.', s: 'Servicios del hogar, documentos, formación y descuentos de socios — un club en Telegram.', c: 'Únete gratis' }
};
const codes = ['RU', 'EN', '中文', 'ՀԱՅ', 'ES'];
const cur = this.state.lang;
const base = 'min-width: 44px; height: 44px; padding: 0 10px; border-radius: 999px; border: none; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; ';
const langs = codes.map((code) => ({ code: code, pick: () => this.setState({ lang: code }), style: base + (code === cur ? 'background: #F6C3D3; color: #4A1028;' : 'background: transparent; color: #1E1B1A;') }));
const qs = [
['Нужно что-то устанавливать?', 'Нет. Клуб работает внутри Telegram: открываешь бота и пользуешься.'],
['На каких языках можно общаться?', 'Русский, английский, китайский, армянский и испанский.'],
['Чем Barev+ отличается от бесплатного?', 'Скидки [N]% на документы, быт, обучение и карьерные консультации.'],
['Как получить подарок от агентства?', 'Код на месяц Barev+ указан в договоре аренды. Введи его в боте.']
];
const faq = qs.map(([q, a], i) => ({ q: q, a: a, open: this.state.open === i, sign: this.state.open === i ? '−' : '+', toggle: () => this.setState({ open: this.state.open === i ? -1 : i }) }));
const c = copy[cur];
return { langs: langs, title: c.t, sub: c.s, cta: c.c, faq: faq };
}
}
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
# Лендинг v2 · плотный — design reference
This is a design mockup created in a visual design tool, exported as a
standalone page. Treat it as a REFERENCE MOCKUP, not production code:
the markup and inline styles carry the design's precise values — colors,
font sizes, spacing, radii, shadows, layout — which an implementation
should replicate faithfully in its own components and styling system
rather than copy wholesale.
## Contents
- `MainV2.dc.html` — the artboard (a Design Component: an `<x-dc>`
template + a small logic class). The values to replicate live in its
inline `style="…"` attributes and the `<helmet><style>` block.
- `support.js`, `vendor/react*.js` — the runtime that renders the
component in a browser; not part of the design.
## Viewing
Serve the folder (e.g. `python3 -m http.server`) and open `MainV2.dc.html`;
some browsers block the scripts over file://.
+48
View File
@@ -0,0 +1,48 @@
// GENERATED from dc-runtime/src/*.ts - do not edit. Rebuild with `bun build.ts` (see the package README for the source-only recipe).
"use strict";(()=>{var Rt=Object.defineProperty,Mt=(t,n,e)=>n in t?Rt(t,n,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[n]=e,T=(t,n,e)=>Mt(t,typeof n!="symbol"?n+"":n,e);function L(){const t=window.React;if(!t)throw new Error("dc-runtime: window.React is not available yet");return t}function Tt(){const t=window.ReactDOM;if(!t)throw new Error("dc-runtime: window.ReactDOM is not available yet");return t}var A=((...t)=>L().createElement(...t));function Lt(t){const n=t.querySelector("x-dc");if(!n)return null;const e=t.querySelector("script[data-dc-script]"),{props:r,preview:o}=B(e?.getAttribute("data-props")??null);return{template:n.innerHTML,js:e&&e.textContent||"",props:r,preview:o}}function tt(t){const n=/<x-dc(?:\s[^>]*)?>/.exec(t);if(!n)return null;const e=t.lastIndexOf("</x-dc>");if(e===-1||e<n.index)return null;const r=t.slice(n.index+n[0].length,e),s=new DOMParser().parseFromString(t,"text/html").querySelector("script[data-dc-script]"),{props:i,preview:l}=B(s?.getAttribute("data-props")??null);return{template:r,js:s&&s.textContent||"",props:i,preview:l}}function B(t){if(!t)return{props:null,preview:null};let n;try{n=JSON.parse(t)}catch{return{props:null,preview:null}}if(!n||typeof n!="object"||Array.isArray(n))return{props:null,preview:null};const e=n,r=e.$preview&&typeof e.$preview=="object"?e.$preview:null,o={};for(const s of Object.keys(e))s[0]!=="$"&&(o[s]=e[s]);return{props:Object.keys(o).length?o:null,preview:r}}function Pt(t){let n=t||"";try{n=decodeURIComponent(n)}catch{}return(n.split("/").pop()||"Root").replace(/\.dc\.html$|\.html?$/,"")||"Root"}var Dt=`
.sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent);
border:1px solid color-mix(in srgb,currentColor 50%,transparent);
border-radius:2px;box-sizing:border-box;overflow:hidden}
@keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}}
html.sc-dc-streaming .sc-placeholder,
html.sc-dc-streaming .sc-interp.sc-missing{position:relative;
background:color-mix(in srgb,currentColor 5%,transparent);
border-color:transparent}
html.sc-dc-streaming .sc-placeholder::before,
html.sc-dc-streaming .sc-interp.sc-missing::before{content:'';
position:absolute;inset:0;pointer-events:none;
background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%);
background-size:400% 100%;animation:sc-shine 1.4s ease infinite}
html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before,
html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none;
background:color-mix(in srgb,currentColor 8%,transparent)}
.sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace;
color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word}
.sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden;
vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5);
border-radius:2px;box-sizing:border-box;color:transparent;
user-select:none}
.sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em;
color:color-mix(in srgb,currentColor 50%,transparent);
background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px;
padding:0 3px}
.sc-host.sc-has-error{position:relative}
.sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch;
padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace;
border-radius:4px;white-space:pre-wrap;pointer-events:none}
/* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both
in sync until dc-runtime regains a build step. */
@media print {
@page { margin: 0.5cm; }
figure, table { break-inside: avoid; }
#dc-root, #dc-root > .sc-host { height: auto; }
*, *::before, *::after {
print-color-adjust: exact; -webkit-print-color-adjust: exact;
backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
animation-delay: -99s !important; animation-duration: .001s !important;
animation-iteration-count: 1 !important; animation-fill-mode: both !important;
animation-play-state: running !important; transition-duration: 0s !important;
}
}
`,Ot="html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}";function jt(t,n){let e=n.pathname||"";if(!/\.dc\.html?$/i.test(It(e)))try{e=new URL(t.baseURI||"/").pathname}catch{}return Pt(e)}function It(t){try{return decodeURIComponent(t)}catch{return t}}function zt(t,n=document){const e=Lt(n);if(!e)return null;const r=L(),o=jt(n,location);t.markFetched(o),t.setRootName(o),t.adoptParsed(o,e),window.__resources||fetch(location.href).then(_=>_.ok?_.text():"").then(_=>{const u=_?tt(_):null;u?.template&&t.updateHtml(o,u.template)}).catch(()=>{});const s=n.querySelector("x-dc"),i=n.createElement("div");if(i.id="dc-root",s.replaceWith(i),!e.preview){const _=n.createElement("style");_.textContent=Ot,n.head.appendChild(_)}const l=t.getDC(o),c=t.registry.get(o);function a(){const[,_]=r.useState(0);r.useEffect(()=>{const d=()=>_(g=>g+1);return c.subs.add(d),()=>{c.subs.delete(d)}},[]);const u=r.useMemo(()=>{const d={};for(const g in c.propsMeta||{}){const m=c.propsMeta?.[g]?.default;m!==void 0&&(d[g]=m)}return d},[c.propsMeta]);return A(l,{...u,...c.propOverrides||{}})}const f=Tt();return f.createRoot?f.createRoot(i).render(A(a)):f.render(A(a),i),o}var et=/^[A-Za-z_$][A-Za-z0-9_$]*/,Ut=/^-?\d+(\.\d+)?$/;function D(t,n){const e=String(n).trim();if(!e)return;if(e[0]==="("&&e[e.length-1]===")"&&Gt(e))return D(t,e.slice(1,-1));const r=Ft(e);if(r){const o=D(t,e.slice(0,r.index)),s=D(t,e.slice(r.index+r.op.length));switch(r.op){case"===":return o===s;case"!==":return o!==s;case"==":return o==s;default:return o!=s}}if(e[0]==="!")return!D(t,e.slice(1));if(e==="true")return!0;if(e==="false")return!1;if(e==="null")return null;if(e!=="undefined")return Ut.test(e)?Number(e):e.length>=2&&(e[0]==='"'||e[0]==="'")&&e[e.length-1]===e[0]?e.slice(1,-1):Bt(t,e)}function Gt(t){let n=0;for(let e=0;e<t.length-1;e++)if(t[e]==="(")n++;else if(t[e]===")"&&(n--,n===0))return!1;return!0}function Ft(t){let n=0;for(let e=0;e<t.length;e++){const r=t[e];if(r==="["||r==="(")n++;else if(r==="]"||r===")")n--;else if(n===0&&(r==="="||r==="!")&&t[e+1]==="="){if(e>0&&(t[e-1]==="="||t[e-1]==="!")||!t.slice(0,e).trim())continue;const o=t[e+2]==="="?r+"==":r+"=";return{index:e,op:o}}}return null}function Bt(t,n){const e=n.match(et);if(!e)return;let r=t?.[e[0]],o=e[0].length;for(;o<n.length;)if(n[o]==="."){const s=n.slice(o+1).match(et)||n.slice(o+1).match(/^\d+/);if(!s)return;r=r?.[s[0]],o+=1+s[0].length}else if(n[o]==="["){let s=1,i=o+1;for(;i<n.length&&s>0;){if(n[i]==="[")s++;else if(n[i]==="]"&&(s--,s===0))break;i++}if(s!==0)return;const l=D(t,n.slice(o+1,i));r=r?.[l],o=i+1}else return;return r}var W="sc-camel-",Wt=new Set("a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split(" ")),nt={select:"sc-raw-select",table:"sc-raw-table",tbody:"sc-raw-tbody",thead:"sc-raw-thead",tfoot:"sc-raw-tfoot",tr:"sc-raw-tr",td:"sc-raw-td",th:"sc-raw-th",caption:"sc-raw-caption"},rt=Object.fromEntries(Object.entries(nt).map(([t,n])=>[n,t])),$t=new Set("allowFullScreen async autoFocus autoPlay checked controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden itemScope loop multiple muted noModule noValidate open playsInline readOnly required reversed scoped seamless selected".split(" ")),qt={onclick:"onClick",onchange:"onChange",oninput:"onInput",onsubmit:"onSubmit",onkeydown:"onKeyDown",onkeyup:"onKeyUp",onkeypress:"onKeyPress",onmousedown:"onMouseDown",onmouseup:"onMouseUp",onmouseenter:"onMouseEnter",onmouseleave:"onMouseLeave",onfocus:"onFocus",onblur:"onBlur",ondoubleclick:"onDoubleClick",oncontextmenu:"onContextMenu",onmousemove:"onMouseMove",onmouseover:"onMouseOver",onmouseout:"onMouseOut",onpointerdown:"onPointerDown",onpointerup:"onPointerUp",onpointermove:"onPointerMove",onpointerenter:"onPointerEnter",onpointerleave:"onPointerLeave",onpointercancel:"onPointerCancel",onpointerover:"onPointerOver",onpointerout:"onPointerOut",ongotpointercapture:"onGotPointerCapture",onlostpointercapture:"onLostPointerCapture",ontouchstart:"onTouchStart",ontouchend:"onTouchEnd",ontouchmove:"onTouchMove",ontouchcancel:"onTouchCancel",ondragstart:"onDragStart",ondragend:"onDragEnd",ondragenter:"onDragEnter",ondragleave:"onDragLeave",ondragover:"onDragOver",onanimationstart:"onAnimationStart",onanimationend:"onAnimationEnd",onanimationiteration:"onAnimationIteration",ontransitionend:"onTransitionEnd"},Vt=`(?:[^>"']|"[^"]*"|'[^']*')*`,Ht=new RegExp("<(x-import|dc-import)("+Vt+")/>","gi"),ot=`(?:=\\s*"[^"]*"|=\\s*'[^']*'|=(?!\\s*["'])|[^>=])*`,Kt=new RegExp("(<!---?>|<!--[\\s\\S]*?--!?>)|(<(script|style|textarea|title)(?=[\\s/>])"+ot+">)([\\s\\S]*?<\\/\\3(?=[\\s/>])[^>]*>)|<[a-zA-Z][^\\s/>]*(?=[\\s/>])"+ot+">","gi"),Zt=/=\s*"[^"]*"|=\s*'[^']*'|(\s)([a-z]+[A-Z][A-Za-z0-9]*)(?=\s*=)/g;function Jt(t){const n=e=>e.replace(Zt,(r,o,s)=>s===void 0?r:o+W+s.replace(/[A-Z]/g,i=>"-"+i.toLowerCase()));return t.replace(Kt,(e,r,o,s,i)=>r!==void 0?e:o?n(o)+i:n(e))}function Xt(t){tLine truncated
;return (typeof Component!=="undefined"&&Component)||undefined;`)(I,I,L())}function Ue(t,n){if(!n)return!1;const e=Object.keys(t).filter(o=>o!=="children"),r=Object.keys(n).filter(o=>o!=="children");if(e.length!==r.length)return!1;for(const o of e)if(t[o]!==n[o])return!1;return!0}var K,St=t=>t.chrome?{className:t.className,"data-omelette-chrome":""}:{className:t.className};function Z(t){return K?A(K,t):A("div",St(t),t.message)}function F({name:t,hintSize:n,streaming:e,error:r}){const[o,s]=(n||"100%,60px").split(","),i=r?(t?t+": ":"")+r:null;return A("div",{className:"sc-placeholder"+(e?" sc-streaming":""),style:{width:o.trim(),height:s&&s.trim()},title:t},i?Z({className:"sc-placeholder-error",message:i}):null)}function Ge(t){if(!t)return;const[n,e]=t.split(",");return{minWidth:n.trim(),minHeight:e&&e.trim()}}function Fe(t,n,e=()=>{}){const r=L();K=class extends r.Component{constructor(){super(...arguments),T(this,"off")}componentDidMount(){this.off=Et(this.props.message)}componentDidUpdate(c){c.message!==this.props.message&&(this.off?.(),this.off=Et(this.props.message))}componentWillUnmount(){this.off?.()}render(){return A("div",St(this.props),this.props.message)}};const o=r.createContext([]);class s extends r.Component{constructor(a){super(a),T(this,"__name"),T(this,"__sub"),T(this,"__needsDidMount",!1),T(this,"__streamingNow",!1),T(this,"__htmlStreamingNow",!1),T(this,"__failedLogic",null),T(this,"__failedUserProps",null),T(this,"__failedVer",-1),T(this,"__ctorError",null),T(this,"logic"),this.__name=a.__name,this.state={__v:0,__err:null},this.__sub=()=>{this.state.__err&&this.setState({__err:null}),this.forceUpdate()},this.__makeLogic(t.get(this.__name).Logic,null),n(this.__name)}static getDerivedStateFromError(a){return{__err:a instanceof Error&&a.message?a.message:String(a)}}componentDidCatch(a,f){console.error("[dc-runtime] render error in <"+this.__name+">:",a,f?.componentStack||"")}__makeLogic(a,f){const _=a||I;try{this.logic=new _(this.__userProps()),this.__failedLogic=null,this.__failedUserProps=null,this.__ctorError=null}catch(u){console.error(u),this.__failedLogic=a,this.__failedUserProps=this.__userProps(),this.__failedVer=t.get(this.__name).ver,this.__ctorError=this.__name+": "+(u instanceof Error&&u.message?u.message:String(u)),this.logic=new I(this.__userProps())}this.logic.__host=this,f&&(this.logic.state={...this.logic.state||{},...f})}__userProps(){const{__name:a,__hintSize:f,__tplId:_,__hostStyle:u,...d}=this.props;return d}__setLogicState(a,f){const _=this.logic.state,u=typeof a=="function"?a(_):a;this.logic.state={..._,...u},this.setState(d=>({__v:d.__v+1}),f)}__reconcileLogic(){const a=t.get(this.__name),f=a.Logic,_=this.logic.constructor;if(!(f===_||!f&&_===I||f===this.__failedLogic&&a.ver===this.__failedVer&&Ue(this.__userProps(),this.__failedUserProps))){if(!this.__needsDidMount)try{this.logic.componentWillUnmount()}catch(u){console.error(u)}this.__makeLogic(f,this.logic.state),this.__needsDidMount=!0}}componentDidMount(){t.get(this.__name).subs.add(this.__sub);try{this.logic.componentDidMount()}catch(a){console.error(a)}}componentDidUpdate(a){if(this.logic.props=this.__userProps(),this.__needsDidMount){if(this.state.__err||!t.get(this.__name).tpl)return;this.__needsDidMount=!1;try{this.logic.componentDidMount()}catch(f){console.error(f)}}else try{this.logic.componentDidUpdate(a)}catch(f){console.error(f)}}componentWillUnmount(){if(t.get(this.__name).subs.delete(this.__sub),!this.__needsDidMount)try{this.logic.componentWillUnmount()}catch(a){console.error(a)}}render(){e(this.__name);const a=t.get(this.__name),f="sc-host"+(a.htmlStreaming?" sc-streaming-html":"")+(a.jsStreaming?" sc-streaming-js":""),_=a.htmlStreaming?Ge(this.props.__hintSize):void 0,u=this.props.__hostStyle||_?{..._||{},...this.props.__hostStyle||{}}:void 0,d={className:f,style:u,"data-sc-name":this.__name,"data-dc-tpl":this.props.__tplId},g=Array.isArray(this.context)?this.context:[];if(g.includes(this.__name)){const b=[...g.slice(g.indexOf(this.__name)),this.__name].join(" \u2192 ");return A("div",{...d,className:f+" sc-has-error"},A(F,{name:this.__name,hintSize:this.props.__hintSize,error:"circular import: "+b}))}if(this.state.__err)return A("div",{...d,className:f+" sc-has-error"},Z({className:"sc-logic-error",message:this.__name+": "+this.state.__err,chrome:!0}),A(F,{name:this.__name,hintSize:this.props.__hintSize,error:this.state.__err}));if(this.__reconcileLogic(),!a.tpl)return A("div",d,A(F,{name:this.__name,hintSize:this.props.__hintSize}));const m=this.__userProps();this.logic.props=m;let S=m,v=a.logicError||this.__ctorError;try{S={...m,...this.logic.renderVals()||{}}}catch(b){console.error(b),v=this.__name+".renderVals(): "+(b instanceof Error&&b.message?b.message:String(b))}return this.__streamingNow=!!(a.htmlStreaming||a.jsStreaming),this.__htmlStreamingNow=!!a.htmlStreaming,A("div",{...d,className:f+(v?" sc-has-error":"")},v&&Z({className:"sc-logic-error",message:v,chrome:!0}),A(o.Provider,{value:[.. Line truncated
+267
View File
@@ -0,0 +1,267 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
(function(){/*
Modernizr 3.0.0pre (Custom Build) | MIT
*/
'use strict';(function(Q,zb){"object"===typeof exports&&"undefined"!==typeof module?zb(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],zb):(Q=Q||self,zb(Q.ReactDOM={},Q.React))})(this,function(Q,zb){function m(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;c<arguments.length;c++)b+="&args[]="+encodeURIComponent(arguments[c]);return"Minified React error #"+a+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}
function mb(a,b){Ab(a,b);Ab(a+"Capture",b)}function Ab(a,b){$b[a]=b;for(a=0;a<b.length;a++)cg.add(b[a])}function bj(a){if(Zd.call(dg,a))return!0;if(Zd.call(eg,a))return!1;if(cj.test(a))return dg[a]=!0;eg[a]=!0;return!1}function dj(a,b,c,d){if(null!==c&&0===c.type)return!1;switch(typeof b){case "function":case "symbol":return!0;case "boolean":if(d)return!1;if(null!==c)return!c.acceptsBooleans;a=a.toLowerCase().slice(0,5);return"data-"!==a&&"aria-"!==a;default:return!1}}function ej(a,b,c,d){if(null===
b||"undefined"===typeof b||dj(a,b,c,d))return!0;if(d)return!1;if(null!==c)switch(c.type){case 3:return!b;case 4:return!1===b;case 5:return isNaN(b);case 6:return isNaN(b)||1>b}return!1}function Y(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}function $d(a,b,c,d){var e=R.hasOwnProperty(b)?R[b]:null;if(null!==e?0!==e.type:d||!(2<b.length)||"o"!==
b[0]&&"O"!==b[0]||"n"!==b[1]&&"N"!==b[1])ej(b,c,e,d)&&(c=null),d||null===e?bj(b)&&(null===c?a.removeAttribute(b):a.setAttribute(b,""+c)):e.mustUseProperty?a[e.propertyName]=null===c?3===e.type?!1:"":c:(b=e.attributeName,d=e.attributeNamespace,null===c?a.removeAttribute(b):(e=e.type,c=3===e||4===e&&!0===c?"":""+c,d?a.setAttributeNS(d,b,c):a.setAttribute(b,c)))}function ac(a){if(null===a||"object"!==typeof a)return null;a=fg&&a[fg]||a["@@iterator"];return"function"===typeof a?a:null}function bc(a,b,
c){if(void 0===ae)try{throw Error();}catch(d){ae=(b=d.stack.trim().match(/\n( *(at )?)/))&&b[1]||""}return"\n"+ae+a}function be(a,b){if(!a||ce)return"";ce=!0;var c=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(b)if(b=function(){throw Error();},Object.defineProperty(b.prototype,"props",{set:function(){throw Error();}}),"object"===typeof Reflect&&Reflect.construct){try{Reflect.construct(b,[])}catch(n){var d=n}Reflect.construct(a,[],b)}else{try{b.call()}catch(n){d=n}a.call(b.prototype)}else{try{throw Error();
}catch(n){d=n}a()}}catch(n){if(n&&d&&"string"===typeof n.stack){for(var e=n.stack.split("\n"),f=d.stack.split("\n"),g=e.length-1,h=f.length-1;1<=g&&0<=h&&e[g]!==f[h];)h--;for(;1<=g&&0<=h;g--,h--)if(e[g]!==f[h]){if(1!==g||1!==h){do if(g--,h--,0>h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("<anonymous>")&&(k=k.replace("<anonymous>",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{ce=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?bc(a):
""}function fj(a){switch(a.tag){case 5:return bc(a.type);case 16:return bc("Lazy");case 13:return bc("Suspense");case 19:return bc("SuspenseList");case 0:case 2:case 15:return a=be(a.type,!1),a;case 11:return a=be(a.type.render,!1),a;case 1:return a=be(a.type,!0),a;default:return""}}function de(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case Bb:return"Fragment";case Cb:return"Portal";case ee:return"Profiler";case fe:return"StrictMode";
case ge:return"Suspense";case he:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case gg:return(a.displayName||"Context")+".Consumer";case hg:return(a._context.displayName||"Context")+".Provider";case ie:var b=a.render;a=a.displayName;a||(a=b.displayName||b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case je:return b=a.displayName||null,null!==b?b:de(a.type)||"Memo";case Ta:b=a._payload;a=a._init;try{return de(a(b))}catch(c){}}return null}function gj(a){var b=a.type;
switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return de(b);case 8:return b===fe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";
case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Ua(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}}function ig(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"===
b)}function hj(a){var b=ig(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker=
null;delete a[b]}}}}function Pc(a){a._valueTracker||(a._valueTracker=hj(a))}function jg(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=ig(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Qc(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}function ke(a,b){var c=b.checked;return E({},b,{defaultChecked:void 0,defaultValue:void 0,
value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function kg(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Ua(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function lg(a,b){b=b.checked;null!=b&&$d(a,"checked",b,!1)}function le(a,b){lg(a,b);var c=Ua(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!=
c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?me(a,b.type,c):b.hasOwnProperty("defaultValue")&&me(a,b.type,Ua(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}function mg(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue;
c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)}function me(a,b,c){if("number"!==b||Qc(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}function Db(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e<c.length;e++)b["$"+c[e]]=!0;for(c=0;c<a.length;c++)e=b.hasOwnProperty("$"+a[c].value),a[c].selected!==e&&(a[c].selected=e),e&&d&&(a[c].defaultSelected=
!0)}else{c=""+Ua(c);b=null;for(e=0;e<a.length;e++){if(a[e].value===c){a[e].selected=!0;d&&(a[e].defaultSelected=!0);return}null!==b||a[e].disabled||(b=a[e])}null!==b&&(b.selected=!0)}}function ne(a,b){if(null!=b.dangerouslySetInnerHTML)throw Error(m(91));return E({},b,{value:void 0,defaultValue:void 0,children:""+a._wrapperState.initialValue})}function ng(a,b){var c=b.value;if(null==c){c=b.children;b=b.defaultValue;if(null!=c){if(null!=b)throw Error(m(92));if(cc(c)){if(1<c.length)throw Error(m(93));
c=c[0]}b=c}null==b&&(b="");c=b}a._wrapperState={initialValue:Ua(c)}}function og(a,b){var c=Ua(b.value),d=Ua(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d)}function pg(a,b){b=a.textContent;b===a._wrapperState.initialValue&&""!==b&&null!==b&&(a.value=b)}function qg(a){switch(a){case "svg":return"http://www.w3.org/2000/svg";case "math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}
function oe(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?qg(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a}function rg(a,b,c){return null==b||"boolean"===typeof b||""===b?"":c||"number"!==typeof b||0===b||dc.hasOwnProperty(a)&&dc[a]?(""+b).trim():b+"px"}function sg(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf("--"),e=rg(c,b[c],d);"float"===c&&(c="cssFloat");d?a.setProperty(c,e):a[c]=e}}function pe(a,b){if(b){if(ij[a]&&
(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(m(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(m(60));if("object"!==typeof b.dangerouslySetInnerHTML||!("__html"in b.dangerouslySetInnerHTML))throw Error(m(61));}if(null!=b.style&&"object"!==typeof b.style)throw Error(m(62));}}function qe(a,b){if(-1===a.indexOf("-"))return"string"===typeof b.is;switch(a){case "annotation-xml":case "color-profile":case "font-face":case "font-face-src":case "font-face-uri":case "font-face-format":case "font-face-name":case "missing-glyph":return!1;
default:return!0}}function re(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}function tg(a){if(a=ec(a)){if("function"!==typeof se)throw Error(m(280));var b=a.stateNode;b&&(b=Rc(b),se(a.stateNode,a.type,b))}}function ug(a){Eb?Fb?Fb.push(a):Fb=[a]:Eb=a}function vg(){if(Eb){var a=Eb,b=Fb;Fb=Eb=null;tg(a);if(b)for(a=0;a<b.length;a++)tg(b[a])}}function wg(a,b,c){if(te)return a(b,c);te=!0;try{return xg(a,b,c)}finally{if(te=
!1,null!==Eb||null!==Fb)yg(),vg()}}function fc(a,b){var c=a.stateNode;if(null===c)return null;var d=Rc(c);if(null===d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d=!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1}if(a)return null;
if(c&&"function"!==typeof c)throw Error(m(231,b,typeof c));return c}function jj(a,b,c,d,e,f,g,h,k){gc=!1;Sc=null;kj.apply(lj,arguments)}function mj(a,b,c,d,e,f,g,h,k){jj.apply(this,arguments);if(gc){if(gc){var n=Sc;gc=!1;Sc=null}else throw Error(m(198));Tc||(Tc=!0,ue=n)}}function nb(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else{a=b;do b=a,0!==(b.flags&4098)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function zg(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,
null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function Ag(a){if(nb(a)!==a)throw Error(m(188));}function nj(a){var b=a.alternate;if(!b){b=nb(a);if(null===b)throw Error(m(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return Ag(e),a;if(f===d)return Ag(e),b;f=f.sibling}throw Error(m(188));}if(c.return!==d.return)c=e,d=f;
else{for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling}if(!g){for(h=f.child;h;){if(h===c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling}if(!g)throw Error(m(189));}}if(c.alternate!==d)throw Error(m(190));}if(3!==c.tag)throw Error(m(188));return c.stateNode.current===c?a:b}function Bg(a){a=nj(a);return null!==a?Cg(a):null}function Cg(a){if(5===a.tag||6===a.tag)return a;for(a=a.child;null!==a;){var b=Cg(a);if(null!==b)return b;a=a.sibling}return null}
function oj(a,b){if(Ca&&"function"===typeof Ca.onCommitFiberRoot)try{Ca.onCommitFiberRoot(Uc,a,void 0,128===(a.current.flags&128))}catch(c){}}function pj(a){a>>>=0;return 0===a?32:31-(qj(a)/rj|0)|0}function hc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&
4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return a}}function Vc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=hc(h):(f&=g,0!==f&&(d=hc(f)))}else g=c&~e,0!==g?d=hc(g):0!==f&&(d=hc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&
(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0<b;)c=31-ta(b),e=1<<c,d|=a[c],b&=~e;return d}function sj(a,b){switch(a){case 1:case 2:case 4:return b+250;case 8:case 16:case 32:case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return b+5E3;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return-1;
case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tj(a,b){for(var c=a.suspendedLanes,d=a.pingedLanes,e=a.expirationTimes,f=a.pendingLanes;0<f;){var g=31-ta(f),h=1<<g,k=e[g];if(-1===k){if(0===(h&c)||0!==(h&d))e[g]=sj(h,b)}else k<=b&&(a.expiredLanes|=h);f&=~h}}function ve(a){a=a.pendingLanes&-1073741825;return 0!==a?a:a&1073741824?1073741824:0}function Dg(){var a=Wc;Wc<<=1;0===(Wc&4194240)&&(Wc=64);return a}function we(a){for(var b=[],c=0;31>c;c++)b.push(a);
return b}function ic(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-ta(b);a[b]=c}function uj(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0<c;){var e=31-ta(c),f=1<<e;b[e]=0;d[e]=-1;a[e]=-1;c&=~f}}function xe(a,b){var c=a.entangledLanes|=b;for(a=a.entanglements;c;){var d=31-ta(c),e=1<<d;e&b|a[d]&
b&&(a[d]|=b);c&=~e}}function Eg(a){a&=-a;return 1<a?4<a?0!==(a&268435455)?16:536870912:4:1}function Fg(a,b){switch(a){case "focusin":case "focusout":Va=null;break;case "dragenter":case "dragleave":Wa=null;break;case "mouseover":case "mouseout":Xa=null;break;case "pointerover":case "pointerout":jc.delete(b.pointerId);break;case "gotpointercapture":case "lostpointercapture":kc.delete(b.pointerId)}}function lc(a,b,c,d,e,f){if(null===a||a.nativeEvent!==f)return a={blockedOn:b,domEventName:c,eventSystemFlags:d,
nativeEvent:f,targetContainers:[e]},null!==b&&(b=ec(b),null!==b&&Gg(b)),a;a.eventSystemFlags|=d;b=a.targetContainers;null!==e&&-1===b.indexOf(e)&&b.push(e);return a}function vj(a,b,c,d,e){switch(b){case "focusin":return Va=lc(Va,a,b,c,d,e),!0;case "dragenter":return Wa=lc(Wa,a,b,c,d,e),!0;case "mouseover":return Xa=lc(Xa,a,b,c,d,e),!0;case "pointerover":var f=e.pointerId;jc.set(f,lc(jc.get(f)||null,a,b,c,d,e));return!0;case "gotpointercapture":return f=e.pointerId,kc.set(f,lc(kc.get(f)||null,a,b,
c,d,e)),!0}return!1}function Hg(a){var b=ob(a.target);if(null!==b){var c=nb(b);if(null!==c)if(b=c.tag,13===b){if(b=zg(c),null!==b){a.blockedOn=b;wj(a.priority,function(){xj(c)});return}}else if(3===b&&c.stateNode.current.memoizedState.isDehydrated){a.blockedOn=3===c.tag?c.stateNode.containerInfo:null;return}}a.blockedOn=null}function Xc(a){if(null!==a.blockedOn)return!1;for(var b=a.targetContainers;0<b.length;){var c=ye(a.domEventName,a.eventSystemFlags,b[0],a.nativeEvent);if(null===c){c=a.nativeEvent;
var d=new c.constructor(c.type,c);ze=d;c.target.dispatchEvent(d);ze=null}else return b=ec(c),null!==b&&Gg(b),a.blockedOn=c,!1;b.shift()}return!0}function Ig(a,b,c){Xc(a)&&c.delete(b)}function yj(){Ae=!1;null!==Va&&Xc(Va)&&(Va=null);null!==Wa&&Xc(Wa)&&(Wa=null);null!==Xa&&Xc(Xa)&&(Xa=null);jc.forEach(Ig);kc.forEach(Ig)}function mc(a,b){a.blockedOn===b&&(a.blockedOn=null,Ae||(Ae=!0,Jg(Kg,yj)))}function nc(a){if(0<Yc.length){mc(Yc[0],a);for(var b=1;b<Yc.length;b++){var c=Yc[b];c.blockedOn===a&&(c.blockedOn=
null)}}null!==Va&&mc(Va,a);null!==Wa&&mc(Wa,a);null!==Xa&&mc(Xa,a);b=function(b){return mc(b,a)};jc.forEach(b);kc.forEach(b);for(b=0;b<Ya.length;b++)c=Ya[b],c.blockedOn===a&&(c.blockedOn=null);for(;0<Ya.length&&(b=Ya[0],null===b.blockedOn);)Hg(b),null===b.blockedOn&&Ya.shift()}function zj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=1,Be(a,b,c,d)}finally{z=e,Gb.transition=f}}function Aj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=4,Be(a,b,c,d)}finally{z=e,Gb.transition=
f}}function Be(a,b,c,d){if(Zc){var e=ye(a,b,c,d);if(null===e)Ce(a,b,d,$c,c),Fg(a,d);else if(vj(e,a,b,c,d))d.stopPropagation();else if(Fg(a,d),b&4&&-1<Bj.indexOf(a)){for(;null!==e;){var f=ec(e);null!==f&&Cj(f);f=ye(a,b,c,d);null===f&&Ce(a,b,d,$c,c);if(f===e)break;e=f}null!==e&&d.stopPropagation()}else Ce(a,b,d,null,c)}}function ye(a,b,c,d){$c=null;a=re(d);a=ob(a);if(null!==a)if(b=nb(a),null===b)a=null;else if(c=b.tag,13===c){a=zg(b);if(null!==a)return a;a=null}else if(3===c){if(b.stateNode.current.memoizedState.isDehydrated)return 3===
b.tag?b.stateNode.containerInfo:null;a=null}else b!==a&&(a=null);$c=a;return null}function Lg(a){switch(a){case "cancel":case "click":case "close":case "contextmenu":case "copy":case "cut":case "auxclick":case "dblclick":case "dragend":case "dragstart":case "drop":case "focusin":case "focusout":case "input":case "invalid":case "keydown":case "keypress":case "keyup":case "mousedown":case "mouseup":case "paste":case "pause":case "play":case "pointercancel":case "pointerdown":case "pointerup":case "ratechange":case "reset":case "resize":case "seeked":case "submit":case "touchcancel":case "touchend":case "touchstart":case "volumechange":case "change":case "selectionchange":case "textInput":case "compositionstart":case "compositionend":case "compositionupdate":case "beforeblur":case "afterblur":case "beforeinput":case "blur":case "fullscreenchange":case "focus":case "hashchange":case "popstate":case "select":case "selectstart":return 1;
case "drag":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "mousemove":case "mouseout":case "mouseover":case "pointermove":case "pointerout":case "pointerover":case "scroll":case "toggle":case "touchmove":case "wheel":case "mouseenter":case "mouseleave":case "pointerenter":case "pointerleave":return 4;case "message":switch(Dj()){case De:return 1;case Mg:return 4;case ad:case Ej:return 16;case Ng:return 536870912;default:return 16}default:return 16}}function Og(){if(bd)return bd;
var a,b=Ee,c=b.length,d,e="value"in Za?Za.value:Za.textContent,f=e.length;for(a=0;a<c&&b[a]===e[a];a++);var g=c-a;for(d=1;d<=g&&b[c-d]===e[f-d];d++);return bd=e.slice(a,1<d?1-d:void 0)}function cd(a){var b=a.keyCode;"charCode"in a?(a=a.charCode,0===a&&13===b&&(a=13)):a=b;10===a&&(a=13);return 32<=a||13===a?a:0}function dd(){return!0}function Pg(){return!1}function ka(a){function b(b,d,e,f,g){this._reactName=b;this._targetInst=e;this.type=d;this.nativeEvent=f;this.target=g;this.currentTarget=null;
for(var c in a)a.hasOwnProperty(c)&&(b=a[c],this[c]=b?b(f):f[c]);this.isDefaultPrevented=(null!=f.defaultPrevented?f.defaultPrevented:!1===f.returnValue)?dd:Pg;this.isPropagationStopped=Pg;return this}E(b.prototype,{preventDefault:function(){this.defaultPrevented=!0;var a=this.nativeEvent;a&&(a.preventDefault?a.preventDefault():"unknown"!==typeof a.returnValue&&(a.returnValue=!1),this.isDefaultPrevented=dd)},stopPropagation:function(){var a=this.nativeEvent;a&&(a.stopPropagation?a.stopPropagation():
"unknown"!==typeof a.cancelBubble&&(a.cancelBubble=!0),this.isPropagationStopped=dd)},persist:function(){},isPersistent:dd});return b}function Fj(a){var b=this.nativeEvent;return b.getModifierState?b.getModifierState(a):(a=Gj[a])?!!b[a]:!1}function Fe(a){return Fj}function Qg(a,b){switch(a){case "keyup":return-1!==Hj.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "focusout":return!0;default:return!1}}function Rg(a){a=a.detail;return"object"===typeof a&&
"data"in a?a.data:null}function Ij(a,b){switch(a){case "compositionend":return Rg(b);case "keypress":if(32!==b.which)return null;Sg=!0;return Tg;case "textInput":return a=b.data,a===Tg&&Sg?null:a;default:return null}}function Jj(a,b){if(Hb)return"compositionend"===a||!Ge&&Qg(a,b)?(a=Og(),bd=Ee=Za=null,Hb=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1<b.char.length)return b.char;if(b.which)return String.fromCharCode(b.which)}return null;
case "compositionend":return Ug&&"ko"!==b.locale?null:b.data;default:return null}}function Vg(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return"input"===b?!!Kj[a.type]:"textarea"===b?!0:!1}function Lj(a){if(!Ia)return!1;a="on"+a;var b=a in document;b||(b=document.createElement("div"),b.setAttribute(a,"return;"),b="function"===typeof b[a]);return b}function Wg(a,b,c,d){ug(d);b=ed(b,"onChange");0<b.length&&(c=new He("onChange","change",null,c,d),a.push({event:c,listeners:b}))}function Mj(a){Xg(a,
0)}function fd(a){var b=Ib(a);if(jg(b))return a}function Nj(a,b){if("change"===a)return b}function Yg(){oc&&(oc.detachEvent("onpropertychange",Zg),pc=oc=null)}function Zg(a){if("value"===a.propertyName&&fd(pc)){var b=[];Wg(b,pc,a,re(a));wg(Mj,b)}}function Oj(a,b,c){"focusin"===a?(Yg(),oc=b,pc=c,oc.attachEvent("onpropertychange",Zg)):"focusout"===a&&Yg()}function Pj(a,b){if("selectionchange"===a||"keyup"===a||"keydown"===a)return fd(pc)}function Qj(a,b){if("click"===a)return fd(b)}function Rj(a,b){if("input"===
a||"change"===a)return fd(b)}function Sj(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}function qc(a,b){if(ua(a,b))return!0;if("object"!==typeof a||null===a||"object"!==typeof b||null===b)return!1;var c=Object.keys(a),d=Object.keys(b);if(c.length!==d.length)return!1;for(d=0;d<c.length;d++){var e=c[d];if(!Zd.call(b,e)||!ua(a[e],b[e]))return!1}return!0}function $g(a){for(;a&&a.firstChild;)a=a.firstChild;return a}function ah(a,b){var c=$g(a);a=0;for(var d;c;){if(3===c.nodeType){d=a+c.textContent.length;
if(a<=b&&d>=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=$g(c)}}function bh(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?bh(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function ch(){for(var a=window,b=Qc();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;
b=Qc(a.document)}return b}function Ie(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function Tj(a){var b=ch(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&bh(c.ownerDocument.documentElement,c)){if(null!==d&&Ie(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);
else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=ah(c,f);var g=ah(c,d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),
a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;c<b.length;c++)a=b[c],a.element.scrollLeft=a.left,a.element.scrollTop=a.top}}function dh(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Je||null==Jb||Jb!==Qc(d)||(d=Jb,"selectionStart"in d&&Ie(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d=
{anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),rc&&qc(rc,d)||(rc=d,d=ed(Ke,"onSelect"),0<d.length&&(b=new He("onSelect","select",null,b,c),a.push({event:b,listeners:d}),b.target=Jb)))}function gd(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}function hd(a){if(Le[a])return Le[a];if(!Kb[a])return a;var b=Kb[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in eh)return Le[a]=b[c];return a}function $a(a,
b){fh.set(a,b);mb(b,[a])}function gh(a,b,c){var d=a.type||"unknown-event";a.currentTarget=c;mj(d,b,void 0,a);a.currentTarget=null}function Xg(a,b){b=0!==(b&4);for(var c=0;c<a.length;c++){var d=a[c],e=d.event;d=d.listeners;a:{var f=void 0;if(b)for(var g=d.length-1;0<=g;g--){var h=d[g],k=h.instance,n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;gh(e,h,n);f=k}else for(g=0;g<d.length;g++){h=d[g];k=h.instance;n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;
gh(e,h,n);f=k}}}if(Tc)throw a=ue,Tc=!1,ue=null,a;}function B(a,b){var c=b[Me];void 0===c&&(c=b[Me]=new Set);var d=a+"__bubble";c.has(d)||(hh(b,a,2,!1),c.add(d))}function Ne(a,b,c){var d=0;b&&(d|=4);hh(c,a,d,b)}function sc(a){if(!a[id]){a[id]=!0;cg.forEach(function(b){"selectionchange"!==b&&(Uj.has(b)||Ne(b,!1,a),Ne(b,!0,a))});var b=9===a.nodeType?a:a.ownerDocument;null===b||b[id]||(b[id]=!0,Ne("selectionchange",!1,b))}}function hh(a,b,c,d,e){switch(Lg(b)){case 1:e=zj;break;case 4:e=Aj;break;default:e=
Be}c=e.bind(null,b,c,a);e=void 0;!Oe||"touchstart"!==b&&"touchmove"!==b&&"wheel"!==b||(e=!0);d?void 0!==e?a.addEventListener(b,c,{capture:!0,passive:e}):a.addEventListener(b,c,!0):void 0!==e?a.addEventListener(b,c,{passive:e}):a.addEventListener(b,c,!1)}function Ce(a,b,c,d,e){var f=d;if(0===(b&1)&&0===(b&2)&&null!==d)a:for(;;){if(null===d)return;var g=d.tag;if(3===g||4===g){var h=d.stateNode.containerInfo;if(h===e||8===h.nodeType&&h.parentNode===e)break;if(4===g)for(g=d.return;null!==g;){var k=g.tag;
if(3===k||4===k)if(k=g.stateNode.containerInfo,k===e||8===k.nodeType&&k.parentNode===e)return;g=g.return}for(;null!==h;){g=ob(h);if(null===g)return;k=g.tag;if(5===k||6===k){d=f=g;continue a}h=h.parentNode}}d=d.return}wg(function(){var d=f,e=re(c),g=[];a:{var h=fh.get(a);if(void 0!==h){var k=He,m=a;switch(a){case "keypress":if(0===cd(c))break a;case "keydown":case "keyup":k=Vj;break;case "focusin":m="focus";k=Pe;break;case "focusout":m="blur";k=Pe;break;case "beforeblur":case "afterblur":k=Pe;break;
case "click":if(2===c.button)break a;case "auxclick":case "dblclick":case "mousedown":case "mousemove":case "mouseup":case "mouseout":case "mouseover":case "contextmenu":k=ih;break;case "drag":case "dragend":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "dragstart":case "drop":k=Wj;break;case "touchcancel":case "touchend":case "touchmove":case "touchstart":k=Xj;break;case jh:case kh:case lh:k=Yj;break;case mh:k=Zj;break;case "scroll":k=ak;break;case "wheel":k=bk;break;case "copy":case "cut":case "paste":k=
ck;break;case "gotpointercapture":case "lostpointercapture":case "pointercancel":case "pointerdown":case "pointermove":case "pointerout":case "pointerover":case "pointerup":k=nh}var l=0!==(b&4),p=!l&&"scroll"===a,w=l?null!==h?h+"Capture":null:h;l=[];for(var A=d,t;null!==A;){t=A;var M=t.stateNode;5===t.tag&&null!==M&&(t=M,null!==w&&(M=fc(A,w),null!=M&&l.push(tc(A,M,t))));if(p)break;A=A.return}0<l.length&&(h=new k(h,m,null,c,e),g.push({event:h,listeners:l}))}}if(0===(b&7)){a:{h="mouseover"===a||"pointerover"===
a;k="mouseout"===a||"pointerout"===a;if(h&&c!==ze&&(m=c.relatedTarget||c.fromElement)&&(ob(m)||m[Ja]))break a;if(k||h){h=e.window===e?e:(h=e.ownerDocument)?h.defaultView||h.parentWindow:window;if(k){if(m=c.relatedTarget||c.toElement,k=d,m=m?ob(m):null,null!==m&&(p=nb(m),m!==p||5!==m.tag&&6!==m.tag))m=null}else k=null,m=d;if(k!==m){l=ih;M="onMouseLeave";w="onMouseEnter";A="mouse";if("pointerout"===a||"pointerover"===a)l=nh,M="onPointerLeave",w="onPointerEnter",A="pointer";p=null==k?h:Ib(k);t=null==
m?h:Ib(m);h=new l(M,A+"leave",k,c,e);h.target=p;h.relatedTarget=t;M=null;ob(e)===d&&(l=new l(w,A+"enter",m,c,e),l.target=t,l.relatedTarget=p,M=l);p=M;if(k&&m)b:{l=k;w=m;A=0;for(t=l;t;t=Lb(t))A++;t=0;for(M=w;M;M=Lb(M))t++;for(;0<A-t;)l=Lb(l),A--;for(;0<t-A;)w=Lb(w),t--;for(;A--;){if(l===w||null!==w&&l===w.alternate)break b;l=Lb(l);w=Lb(w)}l=null}else l=null;null!==k&&oh(g,h,k,l,!1);null!==m&&null!==p&&oh(g,p,m,l,!0)}}}a:{h=d?Ib(d):window;k=h.nodeName&&h.nodeName.toLowerCase();if("select"===k||"input"===
k&&"file"===h.type)var ma=Nj;else if(Vg(h))if(ph)ma=Rj;else{ma=Pj;var va=Oj}else(k=h.nodeName)&&"input"===k.toLowerCase()&&("checkbox"===h.type||"radio"===h.type)&&(ma=Qj);if(ma&&(ma=ma(a,d))){Wg(g,ma,c,e);break a}va&&va(a,h,d);"focusout"===a&&(va=h._wrapperState)&&va.controlled&&"number"===h.type&&me(h,"number",h.value)}va=d?Ib(d):window;switch(a){case "focusin":if(Vg(va)||"true"===va.contentEditable)Jb=va,Ke=d,rc=null;break;case "focusout":rc=Ke=Jb=null;break;case "mousedown":Je=!0;break;case "contextmenu":case "mouseup":case "dragend":Je=
!1;dh(g,c,e);break;case "selectionchange":if(dk)break;case "keydown":case "keyup":dh(g,c,e)}var ab;if(Ge)b:{switch(a){case "compositionstart":var da="onCompositionStart";break b;case "compositionend":da="onCompositionEnd";break b;case "compositionupdate":da="onCompositionUpdate";break b}da=void 0}else Hb?Qg(a,c)&&(da="onCompositionEnd"):"keydown"===a&&229===c.keyCode&&(da="onCompositionStart");da&&(Ug&&"ko"!==c.locale&&(Hb||"onCompositionStart"!==da?"onCompositionEnd"===da&&Hb&&(ab=Og()):(Za=e,Ee=
"value"in Za?Za.value:Za.textContent,Hb=!0)),va=ed(d,da),0<va.length&&(da=new qh(da,a,null,c,e),g.push({event:da,listeners:va}),ab?da.data=ab:(ab=Rg(c),null!==ab&&(da.data=ab))));if(ab=ek?Ij(a,c):Jj(a,c))d=ed(d,"onBeforeInput"),0<d.length&&(e=new fk("onBeforeInput","beforeinput",null,c,e),g.push({event:e,listeners:d}),e.data=ab)}Xg(g,b)})}function tc(a,b,c){return{instance:a,listener:b,currentTarget:c}}function ed(a,b){for(var c=b+"Capture",d=[];null!==a;){var e=a,f=e.stateNode;5===e.tag&&null!==
f&&(e=f,f=fc(a,c),null!=f&&d.unshift(tc(a,f,e)),f=fc(a,b),null!=f&&d.push(tc(a,f,e)));a=a.return}return d}function Lb(a){if(null===a)return null;do a=a.return;while(a&&5!==a.tag);return a?a:null}function oh(a,b,c,d,e){for(var f=b._reactName,g=[];null!==c&&c!==d;){var h=c,k=h.alternate,n=h.stateNode;if(null!==k&&k===d)break;5===h.tag&&null!==n&&(h=n,e?(k=fc(c,f),null!=k&&g.unshift(tc(c,k,h))):e||(k=fc(c,f),null!=k&&g.push(tc(c,k,h))));c=c.return}0!==g.length&&a.push({event:b,listeners:g})}function rh(a){return("string"===
typeof a?a:""+a).replace(gk,"\n").replace(hk,"")}function jd(a,b,c,d){b=rh(b);if(rh(a)!==b&&c)throw Error(m(425));}function kd(){}function Qe(a,b){return"textarea"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&&null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}function ik(a){setTimeout(function(){throw a;})}function Re(a,b){var c=b,d=0;do{var e=c.nextSibling;a.removeChild(c);if(e&&8===e.nodeType)if(c=
e.data,"/$"===c){if(0===d){a.removeChild(e);nc(b);return}d--}else"$"!==c&&"$?"!==c&&"$!"!==c||d++;c=e}while(c);nc(b)}function Ka(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break;if(8===b){b=a.data;if("$"===b||"$!"===b||"$?"===b)break;if("/$"===b)return null}}return a}function sh(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if("$"===c||"$!"===c||"$?"===c){if(0===b)return a;b--}else"/$"===c&&b++}a=a.previousSibling}return null}function ob(a){var b=a[Da];
if(b)return b;for(var c=a.parentNode;c;){if(b=c[Ja]||c[Da]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=sh(a);null!==a;){if(c=a[Da])return c;a=sh(a)}return b}a=c;c=a.parentNode}return null}function ec(a){a=a[Da]||a[Ja];return!a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Ib(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(m(33));}function Rc(a){return a[uc]||null}function bb(a){return{current:a}}function v(a,b){0>Mb||(a.current=Se[Mb],Se[Mb]=null,Mb--)}
function y(a,b,c){Mb++;Se[Mb]=a.current;a.current=b}function Nb(a,b){var c=a.type.contextTypes;if(!c)return cb;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function ea(a){a=a.childContextTypes;return null!==a&&void 0!==a}function th(a,b,c){if(J.current!==cb)throw Error(m(168));
y(J,b);y(S,c)}function uh(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(m(108,gj(a)||"Unknown",e));return E({},c,d)}function ld(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||cb;pb=J.current;y(J,a);y(S,S.current);return!0}function vh(a,b,c){var d=a.stateNode;if(!d)throw Error(m(169));c?(a=uh(a,b,pb),d.__reactInternalMemoizedMergedChildContext=a,v(S),v(J),y(J,a)):v(S);
y(S,c)}function wh(a){null===La?La=[a]:La.push(a)}function jk(a){md=!0;wh(a)}function db(){if(!Te&&null!==La){Te=!0;var a=0,b=z;try{var c=La;for(z=1;a<c.length;a++){var d=c[a];do d=d(!0);while(null!==d)}La=null;md=!1}catch(e){throw null!==La&&(La=La.slice(a+1)),xh(De,db),e;}finally{z=b,Te=!1}}return null}function qb(a,b){Ob[Pb++]=nd;Ob[Pb++]=od;od=a;nd=b}function yh(a,b,c){na[oa++]=Ma;na[oa++]=Na;na[oa++]=rb;rb=a;var d=Ma;a=Na;var e=32-ta(d)-1;d&=~(1<<e);c+=1;var f=32-ta(b)+e;if(30<f){var g=e-e%5;
f=(d&(1<<g)-1).toString(32);d>>=g;e-=g;Ma=1<<32-ta(b)+e|c<<e|d;Na=f+a}else Ma=1<<f|c<<e|d,Na=a}function Ue(a){null!==a.return&&(qb(a,1),yh(a,1,0))}function Ve(a){for(;a===od;)od=Ob[--Pb],Ob[Pb]=null,nd=Ob[--Pb],Ob[Pb]=null;for(;a===rb;)rb=na[--oa],na[oa]=null,Na=na[--oa],na[oa]=null,Ma=na[--oa],na[oa]=null}function zh(a,b){var c=pa(5,null,null,0);c.elementType="DELETED";c.stateNode=b;c.return=a;b=a.deletions;null===b?(a.deletions=[c],a.flags|=16):b.push(c)}function Ah(a,b){switch(a.tag){case 5:var c=
a.type;b=1!==b.nodeType||c.toLowerCase()!==b.nodeName.toLowerCase()?null:b;return null!==b?(a.stateNode=b,la=a,fa=Ka(b.firstChild),!0):!1;case 6:return b=""===a.pendingProps||3!==b.nodeType?null:b,null!==b?(a.stateNode=b,la=a,fa=null,!0):!1;case 13:return b=8!==b.nodeType?null:b,null!==b?(c=null!==rb?{id:Ma,overflow:Na}:null,a.memoizedState={dehydrated:b,treeContext:c,retryLane:1073741824},c=pa(18,null,null,0),c.stateNode=b,c.return=a,a.child=c,la=a,fa=null,!0):!1;default:return!1}}function We(a){return 0!==
(a.mode&1)&&0===(a.flags&128)}function Xe(a){if(D){var b=fa;if(b){var c=b;if(!Ah(a,b)){if(We(a))throw Error(m(418));b=Ka(c.nextSibling);var d=la;b&&Ah(a,b)?zh(d,c):(a.flags=a.flags&-4097|2,D=!1,la=a)}}else{if(We(a))throw Error(m(418));a.flags=a.flags&-4097|2;D=!1;la=a}}}function Bh(a){for(a=a.return;null!==a&&5!==a.tag&&3!==a.tag&&13!==a.tag;)a=a.return;la=a}function pd(a){if(a!==la)return!1;if(!D)return Bh(a),D=!0,!1;var b;(b=3!==a.tag)&&!(b=5!==a.tag)&&(b=a.type,b="head"!==b&&"body"!==b&&!Qe(a.type,
a.memoizedProps));if(b&&(b=fa)){if(We(a)){for(a=fa;a;)a=Ka(a.nextSibling);throw Error(m(418));}for(;b;)zh(a,b),b=Ka(b.nextSibling)}Bh(a);if(13===a.tag){a=a.memoizedState;a=null!==a?a.dehydrated:null;if(!a)throw Error(m(317));a:{a=a.nextSibling;for(b=0;a;){if(8===a.nodeType){var c=a.data;if("/$"===c){if(0===b){fa=Ka(a.nextSibling);break a}b--}else"$"!==c&&"$!"!==c&&"$?"!==c||b++}a=a.nextSibling}fa=null}}else fa=la?Ka(a.stateNode.nextSibling):null;return!0}function Qb(){fa=la=null;D=!1}function Ye(a){null===
wa?wa=[a]:wa.push(a)}function vc(a,b,c){a=c.ref;if(null!==a&&"function"!==typeof a&&"object"!==typeof a){if(c._owner){c=c._owner;if(c){if(1!==c.tag)throw Error(m(309));var d=c.stateNode}if(!d)throw Error(m(147,a));var e=d,f=""+a;if(null!==b&&null!==b.ref&&"function"===typeof b.ref&&b.ref._stringRef===f)return b.ref;b=function(a){var b=e.refs;null===a?delete b[f]:b[f]=a};b._stringRef=f;return b}if("string"!==typeof a)throw Error(m(284));if(!c._owner)throw Error(m(290,a));}return a}function qd(a,b){a=
Object.prototype.toString.call(b);throw Error(m(31,"[object Object]"===a?"object with keys {"+Object.keys(b).join(", ")+"}":a));}function Ch(a){var b=a._init;return b(a._payload)}function Dh(a){function b(b,c){if(a){var d=b.deletions;null===d?(b.deletions=[c],b.flags|=16):d.push(c)}}function c(c,d){if(!a)return null;for(;null!==d;)b(c,d),d=d.sibling;return null}function d(a,b){for(a=new Map;null!==b;)null!==b.key?a.set(b.key,b):a.set(b.index,b),b=b.sibling;return a}function e(a,b){a=eb(a,b);a.index=
0;a.sibling=null;return a}function f(b,c,d){b.index=d;if(!a)return b.flags|=1048576,c;d=b.alternate;if(null!==d)return d=d.index,d<c?(b.flags|=2,c):d;b.flags|=2;return c}function g(b){a&&null===b.alternate&&(b.flags|=2);return b}function h(a,b,c,d){if(null===b||6!==b.tag)return b=Ze(c,a.mode,d),b.return=a,b;b=e(b,c);b.return=a;return b}function k(a,b,c,d){var f=c.type;if(f===Bb)return l(a,b,c.props.children,d,c.key);if(null!==b&&(b.elementType===f||"object"===typeof f&&null!==f&&f.$$typeof===Ta&&
Ch(f)===b.type))return d=e(b,c.props),d.ref=vc(a,b,c),d.return=a,d;d=rd(c.type,c.key,c.props,null,a.mode,d);d.ref=vc(a,b,c);d.return=a;return d}function n(a,b,c,d){if(null===b||4!==b.tag||b.stateNode.containerInfo!==c.containerInfo||b.stateNode.implementation!==c.implementation)return b=$e(c,a.mode,d),b.return=a,b;b=e(b,c.children||[]);b.return=a;return b}function l(a,b,c,d,f){if(null===b||7!==b.tag)return b=sb(c,a.mode,d,f),b.return=a,b;b=e(b,c);b.return=a;return b}function u(a,b,c){if("string"===
typeof b&&""!==b||"number"===typeof b)return b=Ze(""+b,a.mode,c),b.return=a,b;if("object"===typeof b&&null!==b){switch(b.$$typeof){case sd:return c=rd(b.type,b.key,b.props,null,a.mode,c),c.ref=vc(a,null,b),c.return=a,c;case Cb:return b=$e(b,a.mode,c),b.return=a,b;case Ta:var d=b._init;return u(a,d(b._payload),c)}if(cc(b)||ac(b))return b=sb(b,a.mode,c,null),b.return=a,b;qd(a,b)}return null}function r(a,b,c,d){var e=null!==b?b.key:null;if("string"===typeof c&&""!==c||"number"===typeof c)return null!==
e?null:h(a,b,""+c,d);if("object"===typeof c&&null!==c){switch(c.$$typeof){case sd:return c.key===e?k(a,b,c,d):null;case Cb:return c.key===e?n(a,b,c,d):null;case Ta:return e=c._init,r(a,b,e(c._payload),d)}if(cc(c)||ac(c))return null!==e?null:l(a,b,c,d,null);qd(a,c)}return null}function p(a,b,c,d,e){if("string"===typeof d&&""!==d||"number"===typeof d)return a=a.get(c)||null,h(b,a,""+d,e);if("object"===typeof d&&null!==d){switch(d.$$typeof){case sd:return a=a.get(null===d.key?c:d.key)||null,k(b,a,d,
e);case Cb:return a=a.get(null===d.key?c:d.key)||null,n(b,a,d,e);case Ta:var f=d._init;return p(a,b,c,f(d._payload),e)}if(cc(d)||ac(d))return a=a.get(c)||null,l(b,a,d,e,null);qd(b,d)}return null}function x(e,g,h,k){for(var n=null,m=null,l=g,t=g=0,q=null;null!==l&&t<h.length;t++){l.index>t?(q=l,l=null):q=l.sibling;var A=r(e,l,h[t],k);if(null===A){null===l&&(l=q);break}a&&l&&null===A.alternate&&b(e,l);g=f(A,g,t);null===m?n=A:m.sibling=A;m=A;l=q}if(t===h.length)return c(e,l),D&&qb(e,t),n;if(null===l){for(;t<
h.length;t++)l=u(e,h[t],k),null!==l&&(g=f(l,g,t),null===m?n=l:m.sibling=l,m=l);D&&qb(e,t);return n}for(l=d(e,l);t<h.length;t++)q=p(l,e,t,h[t],k),null!==q&&(a&&null!==q.alternate&&l.delete(null===q.key?t:q.key),g=f(q,g,t),null===m?n=q:m.sibling=q,m=q);a&&l.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function I(e,g,h,k){var n=ac(h);if("function"!==typeof n)throw Error(m(150));h=n.call(h);if(null==h)throw Error(m(151));for(var l=n=null,q=g,t=g=0,A=null,w=h.next();null!==q&&!w.done;t++,w=
h.next()){q.index>t?(A=q,q=null):A=q.sibling;var x=r(e,q,w.value,k);if(null===x){null===q&&(q=A);break}a&&q&&null===x.alternate&&b(e,q);g=f(x,g,t);null===l?n=x:l.sibling=x;l=x;q=A}if(w.done)return c(e,q),D&&qb(e,t),n;if(null===q){for(;!w.done;t++,w=h.next())w=u(e,w.value,k),null!==w&&(g=f(w,g,t),null===l?n=w:l.sibling=w,l=w);D&&qb(e,t);return n}for(q=d(e,q);!w.done;t++,w=h.next())w=p(q,e,t,w.value,k),null!==w&&(a&&null!==w.alternate&&q.delete(null===w.key?t:w.key),g=f(w,g,t),null===l?n=w:l.sibling=
w,l=w);a&&q.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function v(a,d,f,h){"object"===typeof f&&null!==f&&f.type===Bb&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case sd:a:{for(var k=f.key,n=d;null!==n;){if(n.key===k){k=f.type;if(k===Bb){if(7===n.tag){c(a,n.sibling);d=e(n,f.props.children);d.return=a;a=d;break a}}else if(n.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ta&&Ch(k)===n.type){c(a,n.sibling);d=e(n,f.props);d.ref=vc(a,
n,f);d.return=a;a=d;break a}c(a,n);break}else b(a,n);n=n.sibling}f.type===Bb?(d=sb(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=rd(f.type,f.key,f.props,null,a.mode,h),h.ref=vc(a,d,f),h.return=a,a=h)}return g(a);case Cb:a:{for(n=f.key;null!==d;){if(d.key===n)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=$e(f,a.mode,h);d.return=a;
a=d}return g(a);case Ta:return n=f._init,v(a,d,n(f._payload),h)}if(cc(f))return x(a,d,f,h);if(ac(f))return I(a,d,f,h);qd(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Ze(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return v}function af(){bf=Rb=td=null}function cf(a,b){b=ud.current;v(ud);a._currentValue=b}function df(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|=
b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}function Sb(a,b){td=a;bf=Rb=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(ha=!0),a.firstContext=null)}function qa(a){var b=a._currentValue;if(bf!==a)if(a={context:a,memoizedValue:b,next:null},null===Rb){if(null===td)throw Error(m(308));Rb=a;td.dependencies={lanes:0,firstContext:a}}else Rb=Rb.next=a;return b}function ef(a){null===tb?tb=[a]:tb.push(a)}function Eh(a,b,c,d){var e=b.interleaved;
null===e?(c.next=c,ef(b)):(c.next=e.next,e.next=c);b.interleaved=c;return Oa(a,d)}function Oa(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}function ff(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue=
{baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function Pa(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}function fb(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(p&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return kk(a,c)}e=d.interleaved;null===e?(b.next=b,ef(d)):(b.next=e.next,e.next=b);d.interleaved=b;return Oa(a,c)}function vd(a,b,c){b=
b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function Gh(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,
shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=b;c.lastBaseUpdate=b}function wd(a,b,c,d){var e=a.updateQueue;gb=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,n=k.next;k.next=null;null===g?f=n:g.next=n;g=k;var l=a.alternate;null!==l&&(l=l.updateQueue,h=l.lastBaseUpdate,h!==g&&(null===h?l.firstBaseUpdate=n:h.next=n,l.lastBaseUpdate=k))}if(null!==f){var m=e.baseState;g=0;l=
n=k=null;h=f;do{var r=h.lane,p=h.eventTime;if((d&r)===r){null!==l&&(l=l.next={eventTime:p,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});a:{var x=a,v=h;r=b;p=c;switch(v.tag){case 1:x=v.payload;if("function"===typeof x){m=x.call(p,m,r);break a}m=x;break a;case 3:x.flags=x.flags&-65537|128;case 0:x=v.payload;r="function"===typeof x?x.call(p,m,r):x;if(null===r||void 0===r)break a;m=E({},m,r);break a;case 2:gb=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects=
[h]:r.push(h))}else p={eventTime:p,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===l?(n=l=p,k=m):l=l.next=p,g|=r;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===l&&(k=m);e.baseState=k;e.firstBaseUpdate=n;e.lastBaseUpdate=l;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);ra|=g;a.lanes=g;a.memoizedState=m}}function Hh(a,
b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;b<a.length;b++){var d=a[b],e=d.callback;if(null!==e){d.callback=null;d=c;if("function"!==typeof e)throw Error(m(191,e));e.call(d)}}}function ub(a){if(a===wc)throw Error(m(174));return a}function gf(a,b){y(xc,b);y(yc,a);y(Ea,wc);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:oe(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=oe(b,a)}v(Ea);y(Ea,b)}function Tb(a){v(Ea);v(yc);v(xc)}function Ih(a){ub(xc.current);
var b=ub(Ea.current);var c=oe(b,a.type);b!==c&&(y(yc,a),y(Ea,c))}function hf(a){yc.current===a&&(v(Ea),v(yc))}function xd(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||"$?"===c.data||"$!"===c.data))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.flags&128))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=
b.return;b=b.sibling}return null}function jf(){for(var a=0;a<kf.length;a++)kf[a]._workInProgressVersionPrimary=null;kf.length=0}function V(){throw Error(m(321));}function lf(a,b){if(null===b)return!1;for(var c=0;c<b.length&&c<a.length;c++)if(!ua(a[c],b[c]))return!1;return!0}function mf(a,b,c,d,e,f){vb=f;C=b;b.memoizedState=null;b.updateQueue=null;b.lanes=0;yd.current=null===a||null===a.memoizedState?lk:mk;a=c(d,e);if(zc){f=0;do{zc=!1;Ac=0;if(25<=f)throw Error(m(301));f+=1;N=K=null;b.updateQueue=null;
yd.current=nk;a=c(d,e)}while(zc)}yd.current=zd;b=null!==K&&null!==K.next;vb=0;N=K=C=null;Ad=!1;if(b)throw Error(m(300));return a}function nf(){var a=0!==Ac;Ac=0;return a}function Fa(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===N?C.memoizedState=N=a:N=N.next=a;return N}function sa(){if(null===K){var a=C.alternate;a=null!==a?a.memoizedState:null}else a=K.next;var b=null===N?C.memoizedState:N.next;if(null!==b)N=b,K=a;else{if(null===a)throw Error(m(310));K=a;
a={memoizedState:K.memoizedState,baseState:K.baseState,baseQueue:K.baseQueue,queue:K.queue,next:null};null===N?C.memoizedState=N=a:N=N.next=a}return N}function Bc(a,b){return"function"===typeof b?b(a):b}function of(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=K,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g}d.baseQueue=e=f;c.pending=null}if(null!==e){f=e.next;d=d.baseState;var h=g=null,k=null,n=f;do{var l=n.lane;if((vb&
l)===l)null!==k&&(k=k.next={lane:0,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null}),d=n.hasEagerState?n.eagerState:a(d,n.action);else{var u={lane:l,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null};null===k?(h=k=u,g=d):k=k.next=u;C.lanes|=l;ra|=l}n=n.next}while(null!==n&&n!==f);null===k?g=d:k.next=h;ua(d,b.memoizedState)||(ha=!0);b.memoizedState=d;b.baseState=g;b.baseQueue=k;c.lastRenderedState=d}a=c.interleaved;if(null!==a){e=a;do f=
e.lane,C.lanes|=f,ra|=f,e=e.next;while(e!==a)}else null===e&&(c.lanes=0);return[b.memoizedState,c.dispatch]}function pf(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);ua(f,b.memoizedState)||(ha=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f}return[f,d]}function Jh(a,b,c){}function Kh(a,b,c){c=C;var d=sa(),
e=b(),f=!ua(d.memoizedState,e);f&&(d.memoizedState=e,ha=!0);d=d.queue;qf(Lh.bind(null,c,d,a),[a]);if(d.getSnapshot!==b||f||null!==N&&N.memoizedState.tag&1){c.flags|=2048;Cc(9,Mh.bind(null,c,d,e,b),void 0,null);if(null===O)throw Error(m(349));0!==(vb&30)||Nh(c,b,e)}return e}function Nh(a,b,c){a.flags|=16384;a={getSnapshot:b,value:c};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.stores=[a]):(c=b.stores,null===c?b.stores=[a]:c.push(a))}function Mh(a,b,c,d){b.value=c;b.getSnapshot=
d;Oh(b)&&Ph(a)}function Lh(a,b,c){return c(function(){Oh(b)&&Ph(a)})}function Oh(a){var b=a.getSnapshot;a=a.value;try{var c=b();return!ua(a,c)}catch(d){return!0}}function Ph(a){var b=Oa(a,1);null!==b&&xa(b,a,1,-1)}function Qh(a){var b=Fa();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:Bc,lastRenderedState:a};b.queue=a;a=a.dispatch=ok.bind(null,C,a);return[b.memoizedState,a]}function Cc(a,b,c,d){a={tag:a,create:b,
destroy:c,deps:d,next:null};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a}function Rh(a){return sa().memoizedState}function Bd(a,b,c,d){var e=Fa();C.flags|=a;e.memoizedState=Cc(1|b,c,void 0,void 0===d?null:d)}function Cd(a,b,c,d){var e=sa();d=void 0===d?null:d;var f=void 0;if(null!==K){var g=K.memoizedState;f=g.destroy;if(null!==d&&lf(d,g.deps)){e.memoizedState=
Cc(b,c,f,d);return}}C.flags|=a;e.memoizedState=Cc(1|b,c,f,d)}function Sh(a,b){return Bd(8390656,8,a,b)}function qf(a,b){return Cd(2048,8,a,b)}function Th(a,b){return Cd(4,2,a,b)}function Uh(a,b){return Cd(4,4,a,b)}function Vh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null)};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null}}function Wh(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Cd(4,4,Vh.bind(null,b,a),c)}function rf(a,b){}function Xh(a,b){var c=
sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];c.memoizedState=[a,b];return a}function Yh(a,b){var c=sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Zh(a,b,c){if(0===(vb&21))return a.baseState&&(a.baseState=!1,ha=!0),a.memoizedState=c;ua(c,b)||(c=Dg(),C.lanes|=c,ra|=c,a.baseState=!0);return b}function pk(a,b,c){c=z;z=0!==c&&4>c?c:4;a(!0);var d=sf.transition;sf.transition=
{};try{a(!1),b()}finally{z=c,sf.transition=d}}function $h(){return sa().memoizedState}function qk(a,b,c){var d=hb(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,c);else if(c=Eh(a,b,c,d),null!==c){var e=Z();xa(c,a,d,e);ci(c,b,d)}}function ok(a,b,c){var d=hb(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,
h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(ua(h,g)){var k=b.interleaved;null===k?(e.next=e,ef(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(n){}finally{}c=Eh(a,b,e,d);null!==c&&(e=Z(),xa(c,a,d,e),ci(c,b,d))}}function ai(a){var b=a.alternate;return a===C||null!==b&&b===C}function bi(a,b){zc=Ad=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function ci(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function ya(a,b){if(a&&
a.defaultProps){b=E({},b);a=a.defaultProps;for(var c in a)void 0===b[c]&&(b[c]=a[c]);return b}return b}function tf(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:E({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function di(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!qc(c,d)||!qc(e,f):!0}function ei(a,b,c){var d=!1,e=cb;var f=b.contextType;"object"===typeof f&&
null!==f?f=qa(f):(e=ea(b)?pb:J.current,d=b.contextTypes,f=(d=null!==d&&void 0!==d)?Nb(a,e):cb);b=new b(c,f);a.memoizedState=null!==b.state&&void 0!==b.state?b.state:null;b.updater=Dd;a.stateNode=b;b._reactInternals=a;d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=e,a.__reactInternalMemoizedMaskedChildContext=f);return b}function fi(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"===typeof b.UNSAFE_componentWillReceiveProps&&
b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&Dd.enqueueReplaceState(b,b.state,null)}function uf(a,b,c,d){var e=a.stateNode;e.props=c;e.state=a.memoizedState;e.refs={};ff(a);var f=b.contextType;"object"===typeof f&&null!==f?e.context=qa(f):(f=ea(b)?pb:J.current,e.context=Nb(a,f));e.state=a.memoizedState;f=b.getDerivedStateFromProps;"function"===typeof f&&(tf(a,b,f,c),e.state=a.memoizedState);"function"===typeof b.getDerivedStateFromProps||"function"===typeof e.getSnapshotBeforeUpdate||"function"!==
typeof e.UNSAFE_componentWillMount&&"function"!==typeof e.componentWillMount||(b=e.state,"function"===typeof e.componentWillMount&&e.componentWillMount(),"function"===typeof e.UNSAFE_componentWillMount&&e.UNSAFE_componentWillMount(),b!==e.state&&Dd.enqueueReplaceState(e,e.state,null),wd(a,c,e,d),e.state=a.memoizedState);"function"===typeof e.componentDidMount&&(a.flags|=4194308)}function Ub(a,b){try{var c="",d=b;do c+=fj(d),d=d.return;while(d);var e=c}catch(f){e="\nError generating stack: "+f.message+
"\n"+f.stack}return{value:a,source:b,stack:e,digest:null}}function vf(a,b,c){return{value:a,source:null,stack:null!=c?c:null,digest:null!=b?b:null}}function wf(a,b){try{console.error(b.value)}catch(c){setTimeout(function(){throw c;})}}function gi(a,b,c){c=Pa(-1,c);c.tag=3;c.payload={element:null};var d=b.value;c.callback=function(){Ed||(Ed=!0,xf=d);wf(a,b)};return c}function hi(a,b,c){c=Pa(-1,c);c.tag=3;var d=a.type.getDerivedStateFromError;if("function"===typeof d){var e=b.value;c.payload=function(){return d(e)};
c.callback=function(){wf(a,b)}}var f=a.stateNode;null!==f&&"function"===typeof f.componentDidCatch&&(c.callback=function(){wf(a,b);"function"!==typeof d&&(null===ib?ib=new Set([this]):ib.add(this));var c=b.stack;this.componentDidCatch(b.value,{componentStack:null!==c?c:""})});return c}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new rk;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(e.add(c),a=sk.bind(null,a,b,c),b.then(a,a))}function ji(a){do{var b;
if(b=13===a.tag)b=a.memoizedState,b=null!==b?null!==b.dehydrated?!0:!1:!0;if(b)return a;a=a.return}while(null!==a);return null}function ki(a,b,c,d,e){if(0===(a.mode&1))return a===b?a.flags|=65536:(a.flags|=128,c.flags|=131072,c.flags&=-52805,1===c.tag&&(null===c.alternate?c.tag=17:(b=Pa(-1,1),b.tag=2,fb(c,b,1))),c.lanes|=1),a;a.flags|=65536;a.lanes=e;return a}function aa(a,b,c,d){b.child=null===a?li(b,null,c,d):Vb(b,a.child,c,d)}function mi(a,b,c,d,e){c=c.render;var f=b.ref;Sb(b,e);d=mf(a,b,c,d,f,
e);c=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&c&&Ue(b);b.flags|=1;aa(a,b,d,e);return b.child}function ni(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!yf(f)&&void 0===f.defaultProps&&null===c.compare&&void 0===c.defaultProps)return b.tag=15,b.type=f,oi(a,b,f,d,e);a=rd(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(0===(a.lanes&e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:qc;if(c(g,d)&&a.ref===
b.ref)return Qa(a,b,e)}b.flags|=1;a=eb(f,d);a.ref=b.ref;a.return=b;return b.child=a}function oi(a,b,c,d,e){if(null!==a){var f=a.memoizedProps;if(qc(f,d)&&a.ref===b.ref)if(ha=!1,b.pendingProps=d=f,0!==(a.lanes&e))0!==(a.flags&131072)&&(ha=!0);else return b.lanes=a.lanes,Qa(a,b,e)}return zf(a,b,c,d,e)}function pi(a,b,c){var d=b.pendingProps,e=d.children,f=null!==a?a.memoizedState:null;if("hidden"===d.mode)if(0===(b.mode&1))b.memoizedState={baseLanes:0,cachePool:null,transitions:null},y(Ga,ba),ba|=c;
else{if(0===(c&1073741824))return a=null!==f?f.baseLanes|c:c,b.lanes=b.childLanes=1073741824,b.memoizedState={baseLanes:a,cachePool:null,transitions:null},b.updateQueue=null,y(Ga,ba),ba|=a,null;b.memoizedState={baseLanes:0,cachePool:null,transitions:null};d=null!==f?f.baseLanes:c;y(Ga,ba);ba|=d}else null!==f?(d=f.baseLanes|c,b.memoizedState=null):d=c,y(Ga,ba),ba|=d;aa(a,b,e,c);return b.child}function qi(a,b){var c=b.ref;if(null===a&&null!==c||null!==a&&a.ref!==c)b.flags|=512,b.flags|=2097152}function zf(a,
b,c,d,e){var f=ea(c)?pb:J.current;f=Nb(b,f);Sb(b,e);c=mf(a,b,c,d,f,e);d=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&d&&Ue(b);b.flags|=1;aa(a,b,c,e);return b.child}function ri(a,b,c,d,e){if(ea(c)){var f=!0;ld(b)}else f=!1;Sb(b,e);if(null===b.stateNode)Fd(a,b),ei(b,c,d),uf(b,c,d,e),d=!0;else if(null===a){var g=b.stateNode,h=b.memoizedProps;g.props=h;var k=g.context,n=c.contextType;"object"===typeof n&&null!==n?n=qa(n):(n=ea(c)?pb:J.current,n=Nb(b,
n));var l=c.getDerivedStateFromProps,m="function"===typeof l||"function"===typeof g.getSnapshotBeforeUpdate;m||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==d||k!==n)&&fi(b,g,d,n);gb=!1;var r=b.memoizedState;g.state=r;wd(b,d,g,e);k=b.memoizedState;h!==d||r!==k||S.current||gb?("function"===typeof l&&(tf(b,c,l,d),k=b.memoizedState),(h=gb||di(b,c,h,d,r,k,n))?(m||"function"!==typeof g.UNSAFE_componentWillMount&&"function"!==typeof g.componentWillMount||
("function"===typeof g.componentWillMount&&g.componentWillMount(),"function"===typeof g.UNSAFE_componentWillMount&&g.UNSAFE_componentWillMount()),"function"===typeof g.componentDidMount&&(b.flags|=4194308)):("function"===typeof g.componentDidMount&&(b.flags|=4194308),b.memoizedProps=d,b.memoizedState=k),g.props=d,g.state=k,g.context=n,d=h):("function"===typeof g.componentDidMount&&(b.flags|=4194308),d=!1)}else{g=b.stateNode;Fh(a,b);h=b.memoizedProps;n=b.type===b.elementType?h:ya(b.type,h);g.props=
n;m=b.pendingProps;r=g.context;k=c.contextType;"object"===typeof k&&null!==k?k=qa(k):(k=ea(c)?pb:J.current,k=Nb(b,k));var p=c.getDerivedStateFromProps;(l="function"===typeof p||"function"===typeof g.getSnapshotBeforeUpdate)||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==m||r!==k)&&fi(b,g,d,k);gb=!1;r=b.memoizedState;g.state=r;wd(b,d,g,e);var x=b.memoizedState;h!==m||r!==x||S.current||gb?("function"===typeof p&&(tf(b,c,p,d),x=b.memoizedState),
(n=gb||di(b,c,n,d,r,x,k)||!1)?(l||"function"!==typeof g.UNSAFE_componentWillUpdate&&"function"!==typeof g.componentWillUpdate||("function"===typeof g.componentWillUpdate&&g.componentWillUpdate(d,x,k),"function"===typeof g.UNSAFE_componentWillUpdate&&g.UNSAFE_componentWillUpdate(d,x,k)),"function"===typeof g.componentDidUpdate&&(b.flags|=4),"function"===typeof g.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=
4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=x),g.props=d,g.state=x,g.context=k,d=n):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),d=!1)}return Af(a,b,c,d,f,e)}function Af(a,b,c,d,e,f){qi(a,b);var g=0!==(b.flags&128);if(!d&&!g)return e&&vh(b,c,!1),
Qa(a,b,f);d=b.stateNode;tk.current=b;var h=g&&"function"!==typeof c.getDerivedStateFromError?null:d.render();b.flags|=1;null!==a&&g?(b.child=Vb(b,a.child,null,f),b.child=Vb(b,null,h,f)):aa(a,b,h,f);b.memoizedState=d.state;e&&vh(b,c,!0);return b.child}function si(a){var b=a.stateNode;b.pendingContext?th(a,b.pendingContext,b.pendingContext!==b.context):b.context&&th(a,b.context,!1);gf(a,b.containerInfo)}function ti(a,b,c,d,e){Qb();Ye(e);b.flags|=256;aa(a,b,c,d);return b.child}function Bf(a){return{baseLanes:a,
cachePool:null,transitions:null}}function ui(a,b,c){var d=b.pendingProps,e=F.current,f=!1,g=0!==(b.flags&128),h;(h=g)||(h=null!==a&&null===a.memoizedState?!1:0!==(e&2));if(h)f=!0,b.flags&=-129;else if(null===a||null!==a.memoizedState)e|=1;y(F,e&1);if(null===a){Xe(b);a=b.memoizedState;if(null!==a&&(a=a.dehydrated,null!==a))return 0===(b.mode&1)?b.lanes=1:"$!"===a.data?b.lanes=8:b.lanes=1073741824,null;g=d.children;a=d.fallback;return f?(d=b.mode,f=b.child,g={mode:"hidden",children:g},0===(d&1)&&null!==
f?(f.childLanes=0,f.pendingProps=g):f=Gd(g,d,0,null),a=sb(a,d,c,null),f.return=b,a.return=b,f.sibling=a,b.child=f,b.child.memoizedState=Bf(c),b.memoizedState=Cf,a):Df(b,g)}e=a.memoizedState;if(null!==e&&(h=e.dehydrated,null!==h))return uk(a,b,g,d,h,e,c);if(f){f=d.fallback;g=b.mode;e=a.child;h=e.sibling;var k={mode:"hidden",children:d.children};0===(g&1)&&b.child!==e?(d=b.child,d.childLanes=0,d.pendingProps=k,b.deletions=null):(d=eb(e,k),d.subtreeFlags=e.subtreeFlags&14680064);null!==h?f=eb(h,f):(f=
sb(f,g,c,null),f.flags|=2);f.return=b;d.return=b;d.sibling=f;b.child=d;d=f;f=b.child;g=a.child.memoizedState;g=null===g?Bf(c):{baseLanes:g.baseLanes|c,cachePool:null,transitions:g.transitions};f.memoizedState=g;f.childLanes=a.childLanes&~c;b.memoizedState=Cf;return d}f=a.child;a=f.sibling;d=eb(f,{mode:"visible",children:d.children});0===(b.mode&1)&&(d.lanes=c);d.return=b;d.sibling=null;null!==a&&(c=b.deletions,null===c?(b.deletions=[a],b.flags|=16):c.push(a));b.child=d;b.memoizedState=null;return d}
function Df(a,b,c){b=Gd({mode:"visible",children:b},a.mode,0,null);b.return=a;return a.child=b}function Hd(a,b,c,d){null!==d&&Ye(d);Vb(b,a.child,null,c);a=Df(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function uk(a,b,c,d,e,f,g){if(c){if(b.flags&256)return b.flags&=-257,d=vf(Error(m(422))),Hd(a,b,g,d);if(null!==b.memoizedState)return b.child=a.child,b.flags|=128,null;f=d.fallback;e=b.mode;d=Gd({mode:"visible",children:d.children},e,0,null);f=sb(f,e,g,null);f.flags|=2;d.return=
b;f.return=b;d.sibling=f;b.child=d;0!==(b.mode&1)&&Vb(b,a.child,null,g);b.child.memoizedState=Bf(g);b.memoizedState=Cf;return f}if(0===(b.mode&1))return Hd(a,b,g,null);if("$!"===e.data){d=e.nextSibling&&e.nextSibling.dataset;if(d)var h=d.dgst;d=h;f=Error(m(419));d=vf(f,d,void 0);return Hd(a,b,g,d)}h=0!==(g&a.childLanes);if(ha||h){d=O;if(null!==d){switch(g&-g){case 4:e=2;break;case 16:e=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:e=
32;break;case 536870912:e=268435456;break;default:e=0}e=0!==(e&(d.suspendedLanes|g))?0:e;0!==e&&e!==f.retryLane&&(f.retryLane=e,Oa(a,e),xa(d,a,e,-1))}Ef();d=vf(Error(m(421)));return Hd(a,b,g,d)}if("$?"===e.data)return b.flags|=128,b.child=a.child,b=vk.bind(null,a),e._reactRetry=b,null;a=f.treeContext;fa=Ka(e.nextSibling);la=b;D=!0;wa=null;null!==a&&(na[oa++]=Ma,na[oa++]=Na,na[oa++]=rb,Ma=a.id,Na=a.overflow,rb=b);b=Df(b,d.children);b.flags|=4096;return b}function vi(a,b,c){a.lanes|=b;var d=a.alternate;
null!==d&&(d.lanes|=b);df(a.return,b,c)}function Ff(a,b,c,d,e){var f=a.memoizedState;null===f?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e}:(f.isBackwards=b,f.rendering=null,f.renderingStartTime=0,f.last=d,f.tail=c,f.tailMode=e)}function wi(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;aa(a,b,d.children,c);d=F.current;if(0!==(d&2))d=d&1|2,b.flags|=128;else{if(null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&&
vi(a,c,b);else if(19===a.tag)vi(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}d&=1}y(F,d);if(0===(b.mode&1))b.memoizedState=null;else switch(e){case "forwards":c=b.child;for(e=null;null!==c;)a=c.alternate,null!==a&&null===xd(a)&&(e=c),c=c.sibling;c=e;null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null);Ff(b,!1,e,c,f);break;case "backwards":c=
null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===xd(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}Ff(b,!0,c,null,f);break;case "together":Ff(b,!1,null,null,void 0);break;default:b.memoizedState=null}return b.child}function Fd(a,b){0===(b.mode&1)&&null!==a&&(a.alternate=null,b.alternate=null,b.flags|=2)}function Qa(a,b,c){null!==a&&(b.dependencies=a.dependencies);ra|=b.lanes;if(0===(c&b.childLanes))return null;if(null!==a&&b.child!==a.child)throw Error(m(153));if(null!==
b.child){a=b.child;c=eb(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=eb(a,a.pendingProps),c.return=b;c.sibling=null}return b.child}function wk(a,b,c){switch(b.tag){case 3:si(b);Qb();break;case 5:Ih(b);break;case 1:ea(b.type)&&ld(b);break;case 4:gf(b,b.stateNode.containerInfo);break;case 10:var d=b.type._context,e=b.memoizedProps.value;y(ud,d._currentValue);d._currentValue=e;break;case 13:d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return y(F,F.current&
1),b.flags|=128,null;if(0!==(c&b.child.childLanes))return ui(a,b,c);y(F,F.current&1);a=Qa(a,b,c);return null!==a?a.sibling:null}y(F,F.current&1);break;case 19:d=0!==(c&b.childLanes);if(0!==(a.flags&128)){if(d)return wi(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);y(F,F.current);if(d)break;else return null;case 22:case 23:return b.lanes=0,pi(a,b,c)}return Qa(a,b,c)}function Dc(a,b){if(!D)switch(a.tailMode){case "hidden":b=a.tail;for(var c=null;null!==
b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null;break;case "collapsed":c=a.tail;for(var d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null}}function W(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags&14680064,d|=e.flags&14680064,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes,
d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function xk(a,b,c){var d=b.pendingProps;Ve(b);switch(b.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return W(b),null;case 1:return ea(b.type)&&(v(S),v(J)),W(b),null;case 3:d=b.stateNode;Tb();v(S);v(J);jf();d.pendingContext&&(d.context=d.pendingContext,d.pendingContext=null);if(null===a||null===a.child)pd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags&
256)||(b.flags|=1024,null!==wa&&(Gf(wa),wa=null));xi(a,b);W(b);return null;case 5:hf(b);var e=ub(xc.current);c=b.type;if(null!==a&&null!=b.stateNode)yk(a,b,c,d,e),a.ref!==b.ref&&(b.flags|=512,b.flags|=2097152);else{if(!d){if(null===b.stateNode)throw Error(m(166));W(b);return null}a=ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.type;var f=b.memoizedProps;d[Da]=b;d[uc]=f;a=0!==(b.mode&1);switch(c){case "dialog":B("cancel",d);B("close",d);break;case "iframe":case "object":case "embed":B("load",d);break;
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],d);break;case "source":B("error",d);break;case "img":case "image":case "link":B("error",d);B("load",d);break;case "details":B("toggle",d);break;case "input":kg(d,f);B("invalid",d);break;case "select":d._wrapperState={wasMultiple:!!f.multiple};B("invalid",d);break;case "textarea":ng(d,f),B("invalid",d)}pe(c,f);e=null;for(var g in f)if(f.hasOwnProperty(g)){var h=f[g];"children"===g?"string"===typeof h?d.textContent!==h&&(!0!==f.suppressHydrationWarning&&
jd(d.textContent,h,a),e=["children",h]):"number"===typeof h&&d.textContent!==""+h&&(!0!==f.suppressHydrationWarning&&jd(d.textContent,h,a),e=["children",""+h]):$b.hasOwnProperty(g)&&null!=h&&"onScroll"===g&&B("scroll",d)}switch(c){case "input":Pc(d);mg(d,f,!0);break;case "textarea":Pc(d);pg(d);break;case "select":case "option":break;default:"function"===typeof f.onClick&&(d.onclick=kd)}d=e;b.updateQueue=d;null!==d&&(b.flags|=4)}else{g=9===e.nodeType?e:e.ownerDocument;"http://www.w3.org/1999/xhtml"===
a&&(a=qg(c));"http://www.w3.org/1999/xhtml"===a?"script"===c?(a=g.createElement("div"),a.innerHTML="<script>\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Da]=b;a[uc]=d;zk(a,b,!1,!1);b.stateNode=a;a:{g=qe(c,d);switch(c){case "dialog":B("cancel",a);B("close",a);e=d;break;case "iframe":case "object":case "embed":B("load",a);e=d;break;
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],a);e=d;break;case "source":B("error",a);e=d;break;case "img":case "image":case "link":B("error",a);B("load",a);e=d;break;case "details":B("toggle",a);e=d;break;case "input":kg(a,d);e=ke(a,d);B("invalid",a);break;case "option":e=d;break;case "select":a._wrapperState={wasMultiple:!!d.multiple};e=E({},d,{value:void 0});B("invalid",a);break;case "textarea":ng(a,d);e=ne(a,d);B("invalid",a);break;default:e=d}pe(c,e);h=e;for(f in h)if(h.hasOwnProperty(f)){var k=
h[f];"style"===f?sg(a,k):"dangerouslySetInnerHTML"===f?(k=k?k.__html:void 0,null!=k&&yi(a,k)):"children"===f?"string"===typeof k?("textarea"!==c||""!==k)&&Fc(a,k):"number"===typeof k&&Fc(a,""+k):"suppressContentEditableWarning"!==f&&"suppressHydrationWarning"!==f&&"autoFocus"!==f&&($b.hasOwnProperty(f)?null!=k&&"onScroll"===f&&B("scroll",a):null!=k&&$d(a,f,k,g))}switch(c){case "input":Pc(a);mg(a,d,!1);break;case "textarea":Pc(a);pg(a);break;case "option":null!=d.value&&a.setAttribute("value",""+Ua(d.value));
break;case "select":a.multiple=!!d.multiple;f=d.value;null!=f?Db(a,!!d.multiple,f,!1):null!=d.defaultValue&&Db(a,!!d.multiple,d.defaultValue,!0);break;default:"function"===typeof e.onClick&&(a.onclick=kd)}switch(c){case "button":case "input":case "select":case "textarea":d=!!d.autoFocus;break a;case "img":d=!0;break a;default:d=!1}}d&&(b.flags|=4)}null!==b.ref&&(b.flags|=512,b.flags|=2097152)}W(b);return null;case 6:if(a&&null!=b.stateNode)Ak(a,b,a.memoizedProps,d);else{if("string"!==typeof d&&null===
b.stateNode)throw Error(m(166));c=ub(xc.current);ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.memoizedProps;d[Da]=b;if(f=d.nodeValue!==c)if(a=la,null!==a)switch(a.tag){case 3:jd(d.nodeValue,c,0!==(a.mode&1));break;case 5:!0!==a.memoizedProps.suppressHydrationWarning&&jd(d.nodeValue,c,0!==(a.mode&1))}f&&(b.flags|=4)}else d=(9===c.nodeType?c:c.ownerDocument).createTextNode(d),d[Da]=b,b.stateNode=d}W(b);return null;case 13:v(F);d=b.memoizedState;if(null===a||null!==a.memoizedState&&null!==a.memoizedState.dehydrated){if(D&&
null!==fa&&0!==(b.mode&1)&&0===(b.flags&128)){for(f=fa;f;)f=Ka(f.nextSibling);Qb();b.flags|=98560;f=!1}else if(f=pd(b),null!==d&&null!==d.dehydrated){if(null===a){if(!f)throw Error(m(318));f=b.memoizedState;f=null!==f?f.dehydrated:null;if(!f)throw Error(m(317));f[Da]=b}else Qb(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;W(b);f=!1}else null!==wa&&(Gf(wa),wa=null),f=!0;if(!f)return b.flags&65536?b:null}if(0!==(b.flags&128))return b.lanes=c,b;d=null!==d;d!==(null!==a&&null!==a.memoizedState)&&
d&&(b.child.flags|=8192,0!==(b.mode&1)&&(null===a||0!==(F.current&1)?0===L&&(L=3):Ef()));null!==b.updateQueue&&(b.flags|=4);W(b);return null;case 4:return Tb(),xi(a,b),null===a&&sc(b.stateNode.containerInfo),W(b),null;case 10:return cf(b.type._context),W(b),null;case 17:return ea(b.type)&&(v(S),v(J)),W(b),null;case 19:v(F);f=b.memoizedState;if(null===f)return W(b),null;d=0!==(b.flags&128);g=f.rendering;if(null===g)if(d)Dc(f,!1);else{if(0!==L||null!==a&&0!==(a.flags&128))for(a=b.child;null!==a;){g=
xd(a);if(null!==g){b.flags|=128;Dc(f,!1);d=g.updateQueue;null!==d&&(b.updateQueue=d,b.flags|=4);b.subtreeFlags=0;d=c;for(c=b.child;null!==c;)f=c,a=d,f.flags&=14680066,g=f.alternate,null===g?(f.childLanes=0,f.lanes=a,f.child=null,f.subtreeFlags=0,f.memoizedProps=null,f.memoizedState=null,f.updateQueue=null,f.dependencies=null,f.stateNode=null):(f.childLanes=g.childLanes,f.lanes=g.lanes,f.child=g.child,f.subtreeFlags=0,f.deletions=null,f.memoizedProps=g.memoizedProps,f.memoizedState=g.memoizedState,
f.updateQueue=g.updateQueue,f.type=g.type,a=g.dependencies,f.dependencies=null===a?null:{lanes:a.lanes,firstContext:a.firstContext}),c=c.sibling;y(F,F.current&1|2);return b.child}a=a.sibling}null!==f.tail&&P()>Hf&&(b.flags|=128,d=!0,Dc(f,!1),b.lanes=4194304)}else{if(!d)if(a=xd(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dc(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!D)return W(b),null}else 2*P()-f.renderingStartTime>Hf&&1073741824!==c&&(b.flags|=
128,d=!0,Dc(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=b,f.tail=b.sibling,f.renderingStartTime=P(),b.sibling=null,c=F.current,y(F,d?c&1|2:c&1),b;W(b);return null;case 22:case 23:return ba=Ga.current,v(Ga),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(ba&1073741824)&&(W(b),b.subtreeFlags&6&&(b.flags|=8192)):W(b),null;case 24:return null;
case 25:return null}throw Error(m(156,b.tag));}function Bk(a,b,c){Ve(b);switch(b.tag){case 1:return ea(b.type)&&(v(S),v(J)),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Tb(),v(S),v(J),jf(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return hf(b),null;case 13:v(F);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(m(340));Qb()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return v(F),null;case 4:return Tb(),
null;case 10:return cf(b.type._context),null;case 22:case 23:return ba=Ga.current,v(Ga),null;case 24:return null;default:return null}}function Wb(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){G(a,b,d)}else c.current=null}function If(a,b,c){try{c()}catch(d){G(a,b,d)}}function Ck(a,b){Jf=Zc;a=ch();if(Ie(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();
if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(M){c=null;break a}var g=0,h=-1,k=-1,n=0,q=0,u=a,r=null;b:for(;;){for(var p;;){u!==c||0!==e&&3!==u.nodeType||(h=g+e);u!==f||0!==d&&3!==u.nodeType||(k=g+d);3===u.nodeType&&(g+=u.nodeValue.length);if(null===(p=u.firstChild))break;r=u;u=p}for(;;){if(u===a)break b;r===c&&++n===e&&(h=g);r===f&&++q===d&&(k=g);if(null!==(p=u.nextSibling))break;u=r;r=u.parentNode}u=p}c=-1===h||-1===k?null:
{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Kf={focusedElem:a,selectionRange:c};Zc=!1;for(l=b;null!==l;)if(b=l,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,l=a;else for(;null!==l;){b=l;try{var x=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;case 1:if(null!==x){var v=x.memoizedProps,z=x.memoizedState,w=b.stateNode,A=w.getSnapshotBeforeUpdate(b.elementType===b.type?v:ya(b.type,v),z);w.__reactInternalSnapshotBeforeUpdate=A}break;case 3:var t=
b.stateNode.containerInfo;1===t.nodeType?t.textContent="":9===t.nodeType&&t.documentElement&&t.removeChild(t.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(m(163));}}catch(M){G(b,b.return,M)}a=b.sibling;if(null!==a){a.return=b.return;l=a;break}l=b.return}x=zi;zi=!1;return x}function Gc(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&If(b,c,f)}e=e.next}while(e!==d)}}
function Id(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Lf(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}}function Ai(a){var b=a.alternate;null!==b&&(a.alternate=null,Ai(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Da],delete b[uc],delete b[Me],delete b[Dk],
delete b[Ek]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Bi(a){return 5===a.tag||3===a.tag||4===a.tag}function Ci(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Bi(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&
2))return a.stateNode}}function Mf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=kd));else if(4!==d&&(a=a.child,null!==a))for(Mf(a,b,c),a=a.sibling;null!==a;)Mf(a,b,c),a=a.sibling}function Nf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);
else if(4!==d&&(a=a.child,null!==a))for(Nf(a,b,c),a=a.sibling;null!==a;)Nf(a,b,c),a=a.sibling}function jb(a,b,c){for(c=c.child;null!==c;)Di(a,b,c),c=c.sibling}function Di(a,b,c){if(Ca&&"function"===typeof Ca.onCommitFiberUnmount)try{Ca.onCommitFiberUnmount(Uc,c)}catch(h){}switch(c.tag){case 5:X||Wb(c,b);case 6:var d=T,e=za;T=null;jb(a,b,c);T=d;za=e;null!==T&&(za?(a=T,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):T.removeChild(c.stateNode));break;case 18:null!==T&&(za?
(a=T,c=c.stateNode,8===a.nodeType?Re(a.parentNode,c):1===a.nodeType&&Re(a,c),nc(a)):Re(T,c.stateNode));break;case 4:d=T;e=za;T=c.stateNode.containerInfo;za=!0;jb(a,b,c);T=d;za=e;break;case 0:case 11:case 14:case 15:if(!X&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?If(c,b,g):0!==(f&4)&&If(c,b,g));e=e.next}while(e!==d)}jb(a,b,c);break;case 1:if(!X&&(Wb(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props=
c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){G(c,b,h)}jb(a,b,c);break;case 21:jb(a,b,c);break;case 22:c.mode&1?(X=(d=X)||null!==c.memoizedState,jb(a,b,c),X=d):jb(a,b,c);break;default:jb(a,b,c)}}function Ei(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Fk);b.forEach(function(b){var d=Gk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}function Aa(a,b,c){c=b.deletions;if(null!==c)for(var d=0;d<c.length;d++){var e=
c[d];try{var f=a,g=b,h=g;a:for(;null!==h;){switch(h.tag){case 5:T=h.stateNode;za=!1;break a;case 3:T=h.stateNode.containerInfo;za=!0;break a;case 4:T=h.stateNode.containerInfo;za=!0;break a}h=h.return}if(null===T)throw Error(m(160));Di(f,g,e);T=null;za=!1;var k=e.alternate;null!==k&&(k.return=null);e.return=null}catch(n){G(e,b,n)}}if(b.subtreeFlags&12854)for(b=b.child;null!==b;)Fi(b,a),b=b.sibling}function Fi(a,b,c){var d=a.alternate;c=a.flags;switch(a.tag){case 0:case 11:case 14:case 15:Aa(b,a);
Ha(a);if(c&4){try{Gc(3,a,a.return),Id(3,a)}catch(I){G(a,a.return,I)}try{Gc(5,a,a.return)}catch(I){G(a,a.return,I)}}break;case 1:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);break;case 5:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);if(a.flags&32){var e=a.stateNode;try{Fc(e,"")}catch(I){G(a,a.return,I)}}if(c&4&&(e=a.stateNode,null!=e)){var f=a.memoizedProps,g=null!==d?d.memoizedProps:f,h=a.type,k=a.updateQueue;a.updateQueue=null;if(null!==k)try{"input"===h&&"radio"===f.type&&null!=f.name&&lg(e,f);
qe(h,g);var n=qe(h,f);for(g=0;g<k.length;g+=2){var q=k[g],u=k[g+1];"style"===q?sg(e,u):"dangerouslySetInnerHTML"===q?yi(e,u):"children"===q?Fc(e,u):$d(e,q,u,n)}switch(h){case "input":le(e,f);break;case "textarea":og(e,f);break;case "select":var r=e._wrapperState.wasMultiple;e._wrapperState.wasMultiple=!!f.multiple;var p=f.value;null!=p?Db(e,!!f.multiple,p,!1):r!==!!f.multiple&&(null!=f.defaultValue?Db(e,!!f.multiple,f.defaultValue,!0):Db(e,!!f.multiple,f.multiple?[]:"",!1))}e[uc]=f}catch(I){G(a,a.return,
I)}}break;case 6:Aa(b,a);Ha(a);if(c&4){if(null===a.stateNode)throw Error(m(162));e=a.stateNode;f=a.memoizedProps;try{e.nodeValue=f}catch(I){G(a,a.return,I)}}break;case 3:Aa(b,a);Ha(a);if(c&4&&null!==d&&d.memoizedState.isDehydrated)try{nc(b.containerInfo)}catch(I){G(a,a.return,I)}break;case 4:Aa(b,a);Ha(a);break;case 13:Aa(b,a);Ha(a);e=a.child;e.flags&8192&&(f=null!==e.memoizedState,e.stateNode.isHidden=f,!f||null!==e.alternate&&null!==e.alternate.memoizedState||(Of=P()));c&4&&Ei(a);break;case 22:q=
null!==d&&null!==d.memoizedState;a.mode&1?(X=(n=X)||q,Aa(b,a),X=n):Aa(b,a);Ha(a);if(c&8192){n=null!==a.memoizedState;if((a.stateNode.isHidden=n)&&!q&&0!==(a.mode&1))for(l=a,q=a.child;null!==q;){for(u=l=q;null!==l;){r=l;p=r.child;switch(r.tag){case 0:case 11:case 14:case 15:Gc(4,r,r.return);break;case 1:Wb(r,r.return);var x=r.stateNode;if("function"===typeof x.componentWillUnmount){c=r;b=r.return;try{d=c,x.props=d.memoizedProps,x.state=d.memoizedState,x.componentWillUnmount()}catch(I){G(c,b,I)}}break;
case 5:Wb(r,r.return);break;case 22:if(null!==r.memoizedState){Gi(u);continue}}null!==p?(p.return=r,l=p):Gi(u)}q=q.sibling}a:for(q=null,u=a;;){if(5===u.tag){if(null===q){q=u;try{e=u.stateNode,n?(f=e.style,"function"===typeof f.setProperty?f.setProperty("display","none","important"):f.display="none"):(h=u.stateNode,k=u.memoizedProps.style,g=void 0!==k&&null!==k&&k.hasOwnProperty("display")?k.display:null,h.style.display=rg("display",g))}catch(I){G(a,a.return,I)}}}else if(6===u.tag){if(null===q)try{u.stateNode.nodeValue=
n?"":u.memoizedProps}catch(I){G(a,a.return,I)}}else if((22!==u.tag&&23!==u.tag||null===u.memoizedState||u===a)&&null!==u.child){u.child.return=u;u=u.child;continue}if(u===a)break a;for(;null===u.sibling;){if(null===u.return||u.return===a)break a;q===u&&(q=null);u=u.return}q===u&&(q=null);u.sibling.return=u.return;u=u.sibling}}break;case 19:Aa(b,a);Ha(a);c&4&&Ei(a);break;case 21:break;default:Aa(b,a),Ha(a)}}function Ha(a){var b=a.flags;if(b&2){try{a:{for(var c=a.return;null!==c;){if(Bi(c)){var d=c;
break a}c=c.return}throw Error(m(160));}switch(d.tag){case 5:var e=d.stateNode;d.flags&32&&(Fc(e,""),d.flags&=-33);var f=Ci(a);Nf(a,f,e);break;case 3:case 4:var g=d.stateNode.containerInfo,h=Ci(a);Mf(a,h,g);break;default:throw Error(m(161));}}catch(k){G(a,a.return,k)}a.flags&=-3}b&4096&&(a.flags&=-4097)}function Hk(a,b,c){l=a;Hi(a,b,c)}function Hi(a,b,c){for(var d=0!==(a.mode&1);null!==l;){var e=l,f=e.child;if(22===e.tag&&d){var g=null!==e.memoizedState||Jd;if(!g){var h=e.alternate,k=null!==h&&null!==
h.memoizedState||X;h=Jd;var n=X;Jd=g;if((X=k)&&!n)for(l=e;null!==l;)g=l,k=g.child,22===g.tag&&null!==g.memoizedState?Ii(e):null!==k?(k.return=g,l=k):Ii(e);for(;null!==f;)l=f,Hi(f,b,c),f=f.sibling;l=e;Jd=h;X=n}Ji(a,b,c)}else 0!==(e.subtreeFlags&8772)&&null!==f?(f.return=e,l=f):Ji(a,b,c)}}function Ji(a,b,c){for(;null!==l;){b=l;if(0!==(b.flags&8772)){c=b.alternate;try{if(0!==(b.flags&8772))switch(b.tag){case 0:case 11:case 15:X||Id(5,b);break;case 1:var d=b.stateNode;if(b.flags&4&&!X)if(null===c)d.componentDidMount();
else{var e=b.elementType===b.type?c.memoizedProps:ya(b.type,c.memoizedProps);d.componentDidUpdate(e,c.memoizedState,d.__reactInternalSnapshotBeforeUpdate)}var f=b.updateQueue;null!==f&&Hh(b,f,d);break;case 3:var g=b.updateQueue;if(null!==g){c=null;if(null!==b.child)switch(b.child.tag){case 5:c=b.child.stateNode;break;case 1:c=b.child.stateNode}Hh(b,g,c)}break;case 5:var h=b.stateNode;if(null===c&&b.flags&4){c=h;var k=b.memoizedProps;switch(b.type){case "button":case "input":case "select":case "textarea":k.autoFocus&&
c.focus();break;case "img":k.src&&(c.src=k.src)}}break;case 6:break;case 4:break;case 12:break;case 13:if(null===b.memoizedState){var n=b.alternate;if(null!==n){var q=n.memoizedState;if(null!==q){var p=q.dehydrated;null!==p&&nc(p)}}}break;case 19:case 17:case 21:case 22:case 23:case 25:break;default:throw Error(m(163));}X||b.flags&512&&Lf(b)}catch(r){G(b,b.return,r)}}if(b===a){l=null;break}c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Gi(a){for(;null!==l;){var b=l;if(b===
a){l=null;break}var c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Ii(a){for(;null!==l;){var b=l;try{switch(b.tag){case 0:case 11:case 15:var c=b.return;try{Id(4,b)}catch(k){G(b,c,k)}break;case 1:var d=b.stateNode;if("function"===typeof d.componentDidMount){var e=b.return;try{d.componentDidMount()}catch(k){G(b,e,k)}}var f=b.return;try{Lf(b)}catch(k){G(b,f,k)}break;case 5:var g=b.return;try{Lf(b)}catch(k){G(b,g,k)}}}catch(k){G(b,b.return,k)}if(b===a){l=null;break}var h=b.sibling;
if(null!==h){h.return=b.return;l=h;break}l=b.return}}function Hc(){Hf=P()+500}function Z(){return 0!==(p&6)?P():-1!==Kd?Kd:Kd=P()}function hb(a){if(0===(a.mode&1))return 1;if(0!==(p&2)&&0!==U)return U&-U;if(null!==Ik.transition)return 0===Ld&&(Ld=Dg()),Ld;a=z;if(0!==a)return a;a=window.event;a=void 0===a?16:Lg(a.type);return a}function xa(a,b,c,d){if(50<Ic)throw Ic=0,Pf=null,Error(m(185));ic(a,c,d);if(0===(p&2)||a!==O)a===O&&(0===(p&2)&&(Md|=c),4===L&&kb(a,U)),ia(a,d),1===c&&0===p&&0===(b.mode&1)&&
(Hc(),md&&db())}function ia(a,b){var c=a.callbackNode;tj(a,b);var d=Vc(a,a===O?U:0);if(0===d)null!==c&&Ki(c),a.callbackNode=null,a.callbackPriority=0;else if(b=d&-d,a.callbackPriority!==b){null!=c&&Ki(c);if(1===b)0===a.tag?jk(Li.bind(null,a)):wh(Li.bind(null,a)),Jk(function(){0===(p&6)&&db()}),c=null;else{switch(Eg(d)){case 1:c=De;break;case 4:c=Mg;break;case 16:c=ad;break;case 536870912:c=Ng;break;default:c=ad}c=Mi(c,Ni.bind(null,a))}a.callbackPriority=b;a.callbackNode=c}}function Ni(a,b){Kd=-1;
Ld=0;if(0!==(p&6))throw Error(m(327));var c=a.callbackNode;if(Xb()&&a.callbackNode!==c)return null;var d=Vc(a,a===O?U:0);if(0===d)return null;if(0!==(d&30)||0!==(d&a.expiredLanes)||b)b=Nd(a,d);else{b=d;var e=p;p|=2;var f=Oi();if(O!==a||U!==b)Ra=null,Hc(),wb(a,b);do try{Kk();break}catch(h){Pi(a,h)}while(1);af();Od.current=f;p=e;null!==H?b=0:(O=null,U=0,b=L)}if(0!==b){2===b&&(e=ve(a),0!==e&&(d=e,b=Qf(a,e)));if(1===b)throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;if(6===b)kb(a,d);else{e=a.current.alternate;
if(0===(d&30)&&!Lk(e)&&(b=Nd(a,d),2===b&&(f=ve(a),0!==f&&(d=f,b=Qf(a,f))),1===b))throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;a.finishedWork=e;a.finishedLanes=d;switch(b){case 0:case 1:throw Error(m(345));case 2:xb(a,ja,Ra);break;case 3:kb(a,d);if((d&130023424)===d&&(b=Of+500-P(),10<b)){if(0!==Vc(a,0))break;e=a.suspendedLanes;if((e&d)!==d){Z();a.pingedLanes|=a.suspendedLanes&e;break}a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),b);break}xb(a,ja,Ra);break;case 4:kb(a,d);if((d&4194240)===d)break;b=a.eventTimes;
for(e=-1;0<d;){var g=31-ta(d);f=1<<g;g=b[g];g>e&&(e=g);d&=~f}d=e;d=P()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*Mk(d/1960))-d;if(10<d){a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),d);break}xb(a,ja,Ra);break;case 5:xb(a,ja,Ra);break;default:throw Error(m(329));}}}ia(a,P());return a.callbackNode===c?Ni.bind(null,a):null}function Qf(a,b){var c=Kc;a.current.memoizedState.isDehydrated&&(wb(a,b).flags|=256);a=Nd(a,b);2!==a&&(b=ja,ja=c,null!==b&&Gf(b));return a}function Gf(a){null===
ja?ja=a:ja.push.apply(ja,a)}function Lk(a){for(var b=a;;){if(b.flags&16384){var c=b.updateQueue;if(null!==c&&(c=c.stores,null!==c))for(var d=0;d<c.length;d++){var e=c[d],f=e.getSnapshot;e=e.value;try{if(!ua(f(),e))return!1}catch(g){return!1}}}c=b.child;if(b.subtreeFlags&16384&&null!==c)c.return=b,b=c;else{if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return!0;b=b.return}b.sibling.return=b.return;b=b.sibling}}return!0}function kb(a,b){b&=~Sf;b&=~Md;a.suspendedLanes|=b;a.pingedLanes&=
~b;for(a=a.expirationTimes;0<b;){var c=31-ta(b),d=1<<c;a[c]=-1;b&=~d}}function Li(a){if(0!==(p&6))throw Error(m(327));Xb();var b=Vc(a,0);if(0===(b&1))return ia(a,P()),null;var c=Nd(a,b);if(0!==a.tag&&2===c){var d=ve(a);0!==d&&(b=d,c=Qf(a,d))}if(1===c)throw c=Jc,wb(a,0),kb(a,b),ia(a,P()),c;if(6===c)throw Error(m(345));a.finishedWork=a.current.alternate;a.finishedLanes=b;xb(a,ja,Ra);ia(a,P());return null}function Tf(a,b){var c=p;p|=1;try{return a(b)}finally{p=c,0===p&&(Hc(),md&&db())}}function yb(a){null!==
lb&&0===lb.tag&&0===(p&6)&&Xb();var b=p;p|=1;var c=ca.transition,d=z;try{if(ca.transition=null,z=1,a)return a()}finally{z=d,ca.transition=c,p=b,0===(p&6)&&db()}}function wb(a,b){a.finishedWork=null;a.finishedLanes=0;var c=a.timeoutHandle;-1!==c&&(a.timeoutHandle=-1,Nk(c));if(null!==H)for(c=H.return;null!==c;){var d=c;Ve(d);switch(d.tag){case 1:d=d.type.childContextTypes;null!==d&&void 0!==d&&(v(S),v(J));break;case 3:Tb();v(S);v(J);jf();break;case 5:hf(d);break;case 4:Tb();break;case 13:v(F);break;
case 19:v(F);break;case 10:cf(d.type._context);break;case 22:case 23:ba=Ga.current,v(Ga)}c=c.return}O=a;H=a=eb(a.current,null);U=ba=b;L=0;Jc=null;Sf=Md=ra=0;ja=Kc=null;if(null!==tb){for(b=0;b<tb.length;b++)if(c=tb[b],d=c.interleaved,null!==d){c.interleaved=null;var e=d.next,f=c.pending;if(null!==f){var g=f.next;f.next=e;d.next=g}c.pending=d}tb=null}return a}function Pi(a,b){do{var c=H;try{af();yd.current=zd;if(Ad){for(var d=C.memoizedState;null!==d;){var e=d.queue;null!==e&&(e.pending=null);d=d.next}Ad=
!1}vb=0;N=K=C=null;zc=!1;Ac=0;Uf.current=null;if(null===c||null===c.return){L=1;Jc=b;H=null;break}a:{var f=a,g=c.return,h=c,k=b;b=U;h.flags|=32768;if(null!==k&&"object"===typeof k&&"function"===typeof k.then){var n=k,l=h,p=l.tag;if(0===(l.mode&1)&&(0===p||11===p||15===p)){var r=l.alternate;r?(l.updateQueue=r.updateQueue,l.memoizedState=r.memoizedState,l.lanes=r.lanes):(l.updateQueue=null,l.memoizedState=null)}var v=ji(g);if(null!==v){v.flags&=-257;ki(v,g,h,f,b);v.mode&1&&ii(f,n,b);b=v;k=n;var x=b.updateQueue;
if(null===x){var z=new Set;z.add(k);b.updateQueue=z}else x.add(k);break a}else{if(0===(b&1)){ii(f,n,b);Ef();break a}k=Error(m(426))}}else if(D&&h.mode&1){var y=ji(g);if(null!==y){0===(y.flags&65536)&&(y.flags|=256);ki(y,g,h,f,b);Ye(Ub(k,h));break a}}f=k=Ub(k,h);4!==L&&(L=2);null===Kc?Kc=[f]:Kc.push(f);f=g;do{switch(f.tag){case 3:f.flags|=65536;b&=-b;f.lanes|=b;var w=gi(f,k,b);Gh(f,w);break a;case 1:h=k;var A=f.type,t=f.stateNode;if(0===(f.flags&128)&&("function"===typeof A.getDerivedStateFromError||
null!==t&&"function"===typeof t.componentDidCatch&&(null===ib||!ib.has(t)))){f.flags|=65536;b&=-b;f.lanes|=b;var B=hi(f,h,b);Gh(f,B);break a}}f=f.return}while(null!==f)}Qi(c)}catch(ma){b=ma;H===c&&null!==c&&(H=c=c.return);continue}break}while(1)}function Oi(){var a=Od.current;Od.current=zd;return null===a?zd:a}function Ef(){if(0===L||3===L||2===L)L=4;null===O||0===(ra&268435455)&&0===(Md&268435455)||kb(O,U)}function Nd(a,b){var c=p;p|=2;var d=Oi();if(O!==a||U!==b)Ra=null,wb(a,b);do try{Ok();break}catch(e){Pi(a,
e)}while(1);af();p=c;Od.current=d;if(null!==H)throw Error(m(261));O=null;U=0;return L}function Ok(){for(;null!==H;)Ri(H)}function Kk(){for(;null!==H&&!Pk();)Ri(H)}function Ri(a){var b=Qk(a.alternate,a,ba);a.memoizedProps=a.pendingProps;null===b?Qi(a):H=b;Uf.current=null}function Qi(a){var b=a;do{var c=b.alternate;a=b.return;if(0===(b.flags&32768)){if(c=xk(c,b,ba),null!==c){H=c;return}}else{c=Bk(c,b);if(null!==c){c.flags&=32767;H=c;return}if(null!==a)a.flags|=32768,a.subtreeFlags=0,a.deletions=null;
else{L=6;H=null;return}}b=b.sibling;if(null!==b){H=b;return}H=b=a}while(null!==b);0===L&&(L=5)}function xb(a,b,c){var d=z,e=ca.transition;try{ca.transition=null,z=1,Rk(a,b,c,d)}finally{ca.transition=e,z=d}return null}function Rk(a,b,c,d){do Xb();while(null!==lb);if(0!==(p&6))throw Error(m(327));c=a.finishedWork;var e=a.finishedLanes;if(null===c)return null;a.finishedWork=null;a.finishedLanes=0;if(c===a.current)throw Error(m(177));a.callbackNode=null;a.callbackPriority=0;var f=c.lanes|c.childLanes;
uj(a,f);a===O&&(H=O=null,U=0);0===(c.subtreeFlags&2064)&&0===(c.flags&2064)||Pd||(Pd=!0,Mi(ad,function(){Xb();return null}));f=0!==(c.flags&15990);if(0!==(c.subtreeFlags&15990)||f){f=ca.transition;ca.transition=null;var g=z;z=1;var h=p;p|=4;Uf.current=null;Ck(a,c);Fi(c,a);Tj(Kf);Zc=!!Jf;Kf=Jf=null;a.current=c;Hk(c,a,e);Sk();p=h;z=g;ca.transition=f}else a.current=c;Pd&&(Pd=!1,lb=a,Qd=e);f=a.pendingLanes;0===f&&(ib=null);oj(c.stateNode,d);ia(a,P());if(null!==b)for(d=a.onRecoverableError,c=0;c<b.length;c++)e=
b[c],d(e.value,{componentStack:e.stack,digest:e.digest});if(Ed)throw Ed=!1,a=xf,xf=null,a;0!==(Qd&1)&&0!==a.tag&&Xb();f=a.pendingLanes;0!==(f&1)?a===Pf?Ic++:(Ic=0,Pf=a):Ic=0;db();return null}function Xb(){if(null!==lb){var a=Eg(Qd),b=ca.transition,c=z;try{ca.transition=null;z=16>a?16:a;if(null===lb)var d=!1;else{a=lb;lb=null;Qd=0;if(0!==(p&6))throw Error(m(331));var e=p;p|=4;for(l=a.current;null!==l;){var f=l,g=f.child;if(0!==(l.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;k<h.length;k++){var n=
h[k];for(l=n;null!==l;){var q=l;switch(q.tag){case 0:case 11:case 15:Gc(8,q,f)}var u=q.child;if(null!==u)u.return=q,l=u;else for(;null!==l;){q=l;var r=q.sibling,v=q.return;Ai(q);if(q===n){l=null;break}if(null!==r){r.return=v;l=r;break}l=v}}}var x=f.alternate;if(null!==x){var y=x.child;if(null!==y){x.child=null;do{var C=y.sibling;y.sibling=null;y=C}while(null!==y)}}l=f}}if(0!==(f.subtreeFlags&2064)&&null!==g)g.return=f,l=g;else b:for(;null!==l;){f=l;if(0!==(f.flags&2048))switch(f.tag){case 0:case 11:case 15:Gc(9,
f,f.return)}var w=f.sibling;if(null!==w){w.return=f.return;l=w;break b}l=f.return}}var A=a.current;for(l=A;null!==l;){g=l;var t=g.child;if(0!==(g.subtreeFlags&2064)&&null!==t)t.return=g,l=t;else b:for(g=A;null!==l;){h=l;if(0!==(h.flags&2048))try{switch(h.tag){case 0:case 11:case 15:Id(9,h)}}catch(ma){G(h,h.return,ma)}if(h===g){l=null;break b}var B=h.sibling;if(null!==B){B.return=h.return;l=B;break b}l=h.return}}p=e;db();if(Ca&&"function"===typeof Ca.onPostCommitFiberRoot)try{Ca.onPostCommitFiberRoot(Uc,
a)}catch(ma){}d=!0}return d}finally{z=c,ca.transition=b}}return!1}function Si(a,b,c){b=Ub(c,b);b=gi(a,b,1);a=fb(a,b,1);b=Z();null!==a&&(ic(a,1,b),ia(a,b))}function G(a,b,c){if(3===a.tag)Si(a,a,c);else for(;null!==b;){if(3===b.tag){Si(b,a,c);break}else if(1===b.tag){var d=b.stateNode;if("function"===typeof b.type.getDerivedStateFromError||"function"===typeof d.componentDidCatch&&(null===ib||!ib.has(d))){a=Ub(c,a);a=hi(b,a,1);b=fb(b,a,1);a=Z();null!==b&&(ic(b,1,a),ia(b,a));break}}b=b.return}}function sk(a,
b,c){var d=a.pingCache;null!==d&&d.delete(b);b=Z();a.pingedLanes|=a.suspendedLanes&c;O===a&&(U&c)===c&&(4===L||3===L&&(U&130023424)===U&&500>P()-Of?wb(a,0):Sf|=c);ia(a,b)}function Ti(a,b){0===b&&(0===(a.mode&1)?b=1:(b=Rd,Rd<<=1,0===(Rd&130023424)&&(Rd=4194304)));var c=Z();a=Oa(a,b);null!==a&&(ic(a,b,c),ia(a,c))}function vk(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Ti(a,c)}function Gk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);
break;case 19:d=a.stateNode;break;default:throw Error(m(314));}null!==d&&d.delete(b);Ti(a,c)}function Mi(a,b){return xh(a,b)}function Tk(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function yf(a){a=
a.prototype;return!(!a||!a.isReactComponent)}function Uk(a){if("function"===typeof a)return yf(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===ie)return 11;if(a===je)return 14}return 2}function eb(a,b){var c=a.alternate;null===c?(c=pa(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=
a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}function rd(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)yf(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case Bb:return sb(c.children,e,f,b);case fe:g=8;e|=8;break;case ee:return a=pa(12,c,b,e|2),a.elementType=ee,a.lanes=f,a;case ge:return a=
pa(13,c,b,e),a.elementType=ge,a.lanes=f,a;case he:return a=pa(19,c,b,e),a.elementType=he,a.lanes=f,a;case Ui:return Gd(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case hg:g=10;break a;case gg:g=9;break a;case ie:g=11;break a;case je:g=14;break a;case Ta:g=16;d=null;break a}throw Error(m(130,null==a?a:typeof a,""));}b=pa(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function sb(a,b,c,d){a=pa(7,a,d,b);a.lanes=c;return a}function Gd(a,b,c,d){a=pa(22,a,d,b);a.elementType=
Ui;a.lanes=c;a.stateNode={isHidden:!1};return a}function Ze(a,b,c){a=pa(6,a,null,b);a.lanes=c;return a}function $e(a,b,c){b=pa(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}function Vk(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=
0;this.eventTimes=we(0);this.expirationTimes=we(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=we(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=null}function Vf(a,b,c,d,e,f,g,h,k,l){a=new Vk(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=pa(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,
pendingSuspenseBoundaries:null};ff(f);return a}function Wk(a,b,c){var d=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:Cb,key:null==d?null:""+d,children:a,containerInfo:b,implementation:c}}function Vi(a){if(!a)return cb;a=a._reactInternals;a:{if(nb(a)!==a||1!==a.tag)throw Error(m(170));var b=a;do{switch(b.tag){case 3:b=b.stateNode.context;break a;case 1:if(ea(b.type)){b=b.stateNode.__reactInternalMemoizedMergedChildContext;break a}}b=b.return}while(null!==b);throw Error(m(171));
}if(1===a.tag){var c=a.type;if(ea(c))return uh(a,c,b)}return b}function Wi(a,b,c,d,e,f,g,h,k,l){a=Vf(c,d,!0,a,e,f,g,h,k);a.context=Vi(null);c=a.current;d=Z();e=hb(c);f=Pa(d,e);f.callback=void 0!==b&&null!==b?b:null;fb(c,f,e);a.current.lanes=e;ic(a,e,d);ia(a,d);return a}function Sd(a,b,c,d){var e=b.current,f=Z(),g=hb(e);c=Vi(c);null===b.context?b.context=c:b.pendingContext=c;b=Pa(f,g);b.payload={element:a};d=void 0===d?null:d;null!==d&&(b.callback=d);a=fb(e,b,g);null!==a&&(xa(a,e,g,f),vd(a,e,g));return g}
function Td(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function Xi(a,b){a=a.memoizedState;if(null!==a&&null!==a.dehydrated){var c=a.retryLane;a.retryLane=0!==c&&c<b?c:b}}function Wf(a,b){Xi(a,b);(a=a.alternate)&&Xi(a,b)}function Xk(a){a=Bg(a);return null===a?null:a.stateNode}function Yk(a){return null}function Xf(a){this._internalRoot=a}function Ud(a){this._internalRoot=a}function Yf(a){return!(!a||1!==a.nodeType&&9!==
a.nodeType&&11!==a.nodeType)}function Vd(a){return!(!a||1!==a.nodeType&&9!==a.nodeType&&11!==a.nodeType&&(8!==a.nodeType||" react-mount-point-unstable "!==a.nodeValue))}function Yi(){}function Zk(a,b,c,d,e){if(e){if("function"===typeof d){var f=d;d=function(){var a=Td(g);f.call(a)}}var g=Wi(b,d,a,0,null,!1,!1,"",Yi);a._reactRootContainer=g;a[Ja]=g.current;sc(8===a.nodeType?a.parentNode:a);yb();return g}for(;e=a.lastChild;)a.removeChild(e);if("function"===typeof d){var h=d;d=function(){var a=Td(k);
h.call(a)}}var k=Vf(a,0,!1,null,null,!1,!1,"",Yi);a._reactRootContainer=k;a[Ja]=k.current;sc(8===a.nodeType?a.parentNode:a);yb(function(){Sd(b,k,c,d)});return k}function Wd(a,b,c,d,e){var f=c._reactRootContainer;if(f){var g=f;if("function"===typeof e){var h=e;e=function(){var a=Td(g);h.call(a)}}Sd(b,g,a,e)}else g=Zk(c,b,a,e,d);return Td(g)}var cg=new Set,$b={},Ia=!("undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement),Zd=Object.prototype.hasOwnProperty,
cj=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,eg={},dg={},R={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){R[a]=
new Y(a,0,!1,a,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];R[b]=new Y(b,1,!1,a[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(a){R[a]=new Y(a,2,!1,a.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){R[a]=new Y(a,2,!1,a,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){R[a]=
new Y(a,3,!1,a.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(a){R[a]=new Y(a,3,!0,a,null,!1,!1)});["capture","download"].forEach(function(a){R[a]=new Y(a,4,!1,a,null,!1,!1)});["cols","rows","size","span"].forEach(function(a){R[a]=new Y(a,6,!1,a,null,!1,!1)});["rowSpan","start"].forEach(function(a){R[a]=new Y(a,5,!1,a.toLowerCase(),null,!1,!1)});var Zf=/[\-:]([a-z])/g,$f=function(a){return a[1].toUpperCase()};"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=
a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!1,!1)});R.xlinkHref=new Y("xlinkHref",
1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!0,!0)});var Sa=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,sd=Symbol.for("react.element"),Cb=Symbol.for("react.portal"),Bb=Symbol.for("react.fragment"),fe=Symbol.for("react.strict_mode"),ee=Symbol.for("react.profiler"),hg=Symbol.for("react.provider"),gg=Symbol.for("react.context"),ie=Symbol.for("react.forward_ref"),ge=Symbol.for("react.suspense"),
he=Symbol.for("react.suspense_list"),je=Symbol.for("react.memo"),Ta=Symbol.for("react.lazy");Symbol.for("react.scope");Symbol.for("react.debug_trace_mode");var Ui=Symbol.for("react.offscreen");Symbol.for("react.legacy_hidden");Symbol.for("react.cache");Symbol.for("react.tracing_marker");var fg=Symbol.iterator,E=Object.assign,ae,ce=!1,cc=Array.isArray,Xd,yi=function(a){return"undefined"!==typeof MSApp&&MSApp.execUnsafeLocalFunction?function(b,c,d,e){MSApp.execUnsafeLocalFunction(function(){return a(b,
c,d,e)})}:a}(function(a,b){if("http://www.w3.org/2000/svg"!==a.namespaceURI||"innerHTML"in a)a.innerHTML=b;else{Xd=Xd||document.createElement("div");Xd.innerHTML="<svg>"+b.valueOf().toString()+"</svg>";for(b=Xd.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}}),Fc=function(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b},dc={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,
borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,
strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$k=["Webkit","ms","Moz","O"];Object.keys(dc).forEach(function(a){$k.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);dc[b]=dc[a]})});var ij=E({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),ze=null,se=null,Eb=null,Fb=null,xg=function(a,b){return a(b)},yg=function(){},te=!1,Oe=!1;if(Ia)try{var Lc={};Object.defineProperty(Lc,
"passive",{get:function(){Oe=!0}});window.addEventListener("test",Lc,Lc);window.removeEventListener("test",Lc,Lc)}catch(a){Oe=!1}var kj=function(a,b,c,d,e,f,g,h,k){var l=Array.prototype.slice.call(arguments,3);try{b.apply(c,l)}catch(q){this.onError(q)}},gc=!1,Sc=null,Tc=!1,ue=null,lj={onError:function(a){gc=!0;Sc=a}},Ba=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler,Jg=Ba.unstable_scheduleCallback,Kg=Ba.unstable_NormalPriority,xh=Jg,Ki=Ba.unstable_cancelCallback,Pk=Ba.unstable_shouldYield,
Sk=Ba.unstable_requestPaint,P=Ba.unstable_now,Dj=Ba.unstable_getCurrentPriorityLevel,De=Ba.unstable_ImmediatePriority,Mg=Ba.unstable_UserBlockingPriority,ad=Kg,Ej=Ba.unstable_LowPriority,Ng=Ba.unstable_IdlePriority,Uc=null,Ca=null,ta=Math.clz32?Math.clz32:pj,qj=Math.log,rj=Math.LN2,Wc=64,Rd=4194304,z=0,Ae=!1,Yc=[],Va=null,Wa=null,Xa=null,jc=new Map,kc=new Map,Ya=[],Bj="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit".split(" "),
Gb=Sa.ReactCurrentBatchConfig,Zc=!0,$c=null,Za=null,Ee=null,bd=null,Yb={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(a){return a.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},He=ka(Yb),Mc=E({},Yb,{view:0,detail:0}),ak=ka(Mc),ag,bg,Nc,Yd=E({},Mc,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Fe,button:0,buttons:0,relatedTarget:function(a){return void 0===a.relatedTarget?a.fromElement===a.srcElement?a.toElement:a.fromElement:
a.relatedTarget},movementX:function(a){if("movementX"in a)return a.movementX;a!==Nc&&(Nc&&"mousemove"===a.type?(ag=a.screenX-Nc.screenX,bg=a.screenY-Nc.screenY):bg=ag=0,Nc=a);return ag},movementY:function(a){return"movementY"in a?a.movementY:bg}}),ih=ka(Yd),al=E({},Yd,{dataTransfer:0}),Wj=ka(al),bl=E({},Mc,{relatedTarget:0}),Pe=ka(bl),cl=E({},Yb,{animationName:0,elapsedTime:0,pseudoElement:0}),Yj=ka(cl),dl=E({},Yb,{clipboardData:function(a){return"clipboardData"in a?a.clipboardData:window.clipboardData}}),
ck=ka(dl),el=E({},Yb,{data:0}),qh=ka(el),fk=qh,fl={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},gl={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",
112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},Gj={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"},hl=E({},Mc,{key:function(a){if(a.key){var b=fl[a.key]||a.key;if("Unidentified"!==b)return b}return"keypress"===a.type?(a=cd(a),13===a?"Enter":String.fromCharCode(a)):"keydown"===a.type||"keyup"===a.type?gl[a.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,
metaKey:0,repeat:0,locale:0,getModifierState:Fe,charCode:function(a){return"keypress"===a.type?cd(a):0},keyCode:function(a){return"keydown"===a.type||"keyup"===a.type?a.keyCode:0},which:function(a){return"keypress"===a.type?cd(a):"keydown"===a.type||"keyup"===a.type?a.keyCode:0}}),Vj=ka(hl),il=E({},Yd,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),nh=ka(il),jl=E({},Mc,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,
ctrlKey:0,shiftKey:0,getModifierState:Fe}),Xj=ka(jl),kl=E({},Yb,{propertyName:0,elapsedTime:0,pseudoElement:0}),Zj=ka(kl),ll=E({},Yd,{deltaX:function(a){return"deltaX"in a?a.deltaX:"wheelDeltaX"in a?-a.wheelDeltaX:0},deltaY:function(a){return"deltaY"in a?a.deltaY:"wheelDeltaY"in a?-a.wheelDeltaY:"wheelDelta"in a?-a.wheelDelta:0},deltaZ:0,deltaMode:0}),bk=ka(ll),Hj=[9,13,27,32],Ge=Ia&&"CompositionEvent"in window,Oc=null;Ia&&"documentMode"in document&&(Oc=document.documentMode);var ek=Ia&&"TextEvent"in
window&&!Oc,Ug=Ia&&(!Ge||Oc&&8<Oc&&11>=Oc),Tg=String.fromCharCode(32),Sg=!1,Hb=!1,Kj={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0},oc=null,pc=null,ph=!1;Ia&&(ph=Lj("input")&&(!document.documentMode||9<document.documentMode));var ua="function"===typeof Object.is?Object.is:Sj,dk=Ia&&"documentMode"in document&&11>=document.documentMode,Jb=null,Ke=null,rc=null,Je=!1,Kb={animationend:gd("Animation","AnimationEnd"),
animationiteration:gd("Animation","AnimationIteration"),animationstart:gd("Animation","AnimationStart"),transitionend:gd("Transition","TransitionEnd")},Le={},eh={};Ia&&(eh=document.createElement("div").style,"AnimationEvent"in window||(delete Kb.animationend.animation,delete Kb.animationiteration.animation,delete Kb.animationstart.animation),"TransitionEvent"in window||delete Kb.transitionend.transition);var jh=hd("animationend"),kh=hd("animationiteration"),lh=hd("animationstart"),mh=hd("transitionend"),
fh=new Map,Zi="abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");
(function(){for(var a=0;a<Zi.length;a++){var b=Zi[a],c=b.toLowerCase();b=b[0].toUpperCase()+b.slice(1);$a(c,"on"+b)}$a(jh,"onAnimationEnd");$a(kh,"onAnimationIteration");$a(lh,"onAnimationStart");$a("dblclick","onDoubleClick");$a("focusin","onFocus");$a("focusout","onBlur");$a(mh,"onTransitionEnd")})();Ab("onMouseEnter",["mouseout","mouseover"]);Ab("onMouseLeave",["mouseout","mouseover"]);Ab("onPointerEnter",["pointerout","pointerover"]);Ab("onPointerLeave",["pointerout","pointerover"]);mb("onChange",
"change click focusin focusout input keydown keyup selectionchange".split(" "));mb("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" "));mb("onBeforeInput",["compositionend","keypress","textInput","paste"]);mb("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));
var Ec="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),Uj=new Set("cancel close invalid load scroll toggle".split(" ").concat(Ec)),id="_reactListening"+Math.random().toString(36).slice(2),gk=/\r\n?/g,hk=/\u0000|\uFFFD/g,Jf=null,Kf=null,Rf="function"===typeof setTimeout?setTimeout:void 0,Nk="function"===typeof clearTimeout?
clearTimeout:void 0,$i="function"===typeof Promise?Promise:void 0,Jk="function"===typeof queueMicrotask?queueMicrotask:"undefined"!==typeof $i?function(a){return $i.resolve(null).then(a).catch(ik)}:Rf,Zb=Math.random().toString(36).slice(2),Da="__reactFiber$"+Zb,uc="__reactProps$"+Zb,Ja="__reactContainer$"+Zb,Me="__reactEvents$"+Zb,Dk="__reactListeners$"+Zb,Ek="__reactHandles$"+Zb,Se=[],Mb=-1,cb={},J=bb(cb),S=bb(!1),pb=cb,La=null,md=!1,Te=!1,Ob=[],Pb=0,od=null,nd=0,na=[],oa=0,rb=null,Ma=1,Na="",la=
null,fa=null,D=!1,wa=null,Ik=Sa.ReactCurrentBatchConfig,Vb=Dh(!0),li=Dh(!1),ud=bb(null),td=null,Rb=null,bf=null,tb=null,kk=Oa,gb=!1,wc={},Ea=bb(wc),yc=bb(wc),xc=bb(wc),F=bb(0),kf=[],yd=Sa.ReactCurrentDispatcher,sf=Sa.ReactCurrentBatchConfig,vb=0,C=null,K=null,N=null,Ad=!1,zc=!1,Ac=0,ml=0,zd={readContext:qa,useCallback:V,useContext:V,useEffect:V,useImperativeHandle:V,useInsertionEffect:V,useLayoutEffect:V,useMemo:V,useReducer:V,useRef:V,useState:V,useDebugValue:V,useDeferredValue:V,useTransition:V,
useMutableSource:V,useSyncExternalStore:V,useId:V,unstable_isNewReconciler:!1},lk={readContext:qa,useCallback:function(a,b){Fa().memoizedState=[a,void 0===b?null:b];return a},useContext:qa,useEffect:Sh,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Bd(4194308,4,Vh.bind(null,b,a),c)},useLayoutEffect:function(a,b){return Bd(4194308,4,a,b)},useInsertionEffect:function(a,b){return Bd(4,2,a,b)},useMemo:function(a,b){var c=Fa();b=void 0===b?null:b;a=a();c.memoizedState=
[a,b];return a},useReducer:function(a,b,c){var d=Fa();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=qk.bind(null,C,a);return[d.memoizedState,a]},useRef:function(a){var b=Fa();a={current:a};return b.memoizedState=a},useState:Qh,useDebugValue:rf,useDeferredValue:function(a){return Fa().memoizedState=a},useTransition:function(){var a=Qh(!1),b=a[0];a=pk.bind(null,a[1]);Fa().memoizedState=
a;return[b,a]},useMutableSource:function(a,b,c){},useSyncExternalStore:function(a,b,c){var d=C,e=Fa();if(D){if(void 0===c)throw Error(m(407));c=c()}else{c=b();if(null===O)throw Error(m(349));0!==(vb&30)||Nh(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;Sh(Lh.bind(null,d,f,a),[a]);d.flags|=2048;Cc(9,Mh.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=Fa(),b=O.identifierPrefix;if(D){var c=Na;var d=Ma;c=(d&~(1<<32-ta(d)-1)).toString(32)+c;b=":"+b+"R"+c;c=Ac++;0<c&&
(b+="H"+c.toString(32));b+=":"}else c=ml++,b=":"+b+"r"+c.toString(32)+":";return a.memoizedState=b},unstable_isNewReconciler:!1},mk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:of,useRef:Rh,useState:function(a){return of(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return Zh(b,K.memoizedState,a)},useTransition:function(){var a=of(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,
useSyncExternalStore:Kh,useId:$h,unstable_isNewReconciler:!1},nk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:pf,useRef:Rh,useState:function(a){return pf(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return null===K?b.memoizedState=a:Zh(b,K.memoizedState,a)},useTransition:function(){var a=pf(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,useSyncExternalStore:Kh,useId:$h,
unstable_isNewReconciler:!1},Dd={isMounted:function(a){return(a=a._reactInternals)?nb(a)===a:!1},enqueueSetState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueReplaceState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.tag=1;f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueForceUpdate:function(a,b){a=a._reactInternals;
var c=Z(),d=hb(a),e=Pa(c,d);e.tag=2;void 0!==b&&null!==b&&(e.callback=b);b=fb(a,e,d);null!==b&&(xa(b,a,d,c),vd(b,a,d))}},rk="function"===typeof WeakMap?WeakMap:Map,tk=Sa.ReactCurrentOwner,ha=!1,Cf={dehydrated:null,treeContext:null,retryLane:0};var zk=function(a,b,c,d){for(c=b.child;null!==c;){if(5===c.tag||6===c.tag)a.appendChild(c.stateNode);else if(4!==c.tag&&null!==c.child){c.child.return=c;c=c.child;continue}if(c===b)break;for(;null===c.sibling;){if(null===c.return||c.return===b)return;c=c.return}c.sibling.return=
c.return;c=c.sibling}};var xi=function(a,b){};var yk=function(a,b,c,d,e){var f=a.memoizedProps;if(f!==d){a=b.stateNode;ub(Ea.current);e=null;switch(c){case "input":f=ke(a,f);d=ke(a,d);e=[];break;case "select":f=E({},f,{value:void 0});d=E({},d,{value:void 0});e=[];break;case "textarea":f=ne(a,f);d=ne(a,d);e=[];break;default:"function"!==typeof f.onClick&&"function"===typeof d.onClick&&(a.onclick=kd)}pe(c,d);var g;c=null;for(l in f)if(!d.hasOwnProperty(l)&&f.hasOwnProperty(l)&&null!=f[l])if("style"===
l){var h=f[l];for(g in h)h.hasOwnProperty(g)&&(c||(c={}),c[g]="")}else"dangerouslySetInnerHTML"!==l&&"children"!==l&&"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&($b.hasOwnProperty(l)?e||(e=[]):(e=e||[]).push(l,null));for(l in d){var k=d[l];h=null!=f?f[l]:void 0;if(d.hasOwnProperty(l)&&k!==h&&(null!=k||null!=h))if("style"===l)if(h){for(g in h)!h.hasOwnProperty(g)||k&&k.hasOwnProperty(g)||(c||(c={}),c[g]="");for(g in k)k.hasOwnProperty(g)&&h[g]!==k[g]&&(c||
(c={}),c[g]=k[g])}else c||(e||(e=[]),e.push(l,c)),c=k;else"dangerouslySetInnerHTML"===l?(k=k?k.__html:void 0,h=h?h.__html:void 0,null!=k&&h!==k&&(e=e||[]).push(l,k)):"children"===l?"string"!==typeof k&&"number"!==typeof k||(e=e||[]).push(l,""+k):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&($b.hasOwnProperty(l)?(null!=k&&"onScroll"===l&&B("scroll",a),e||h===k||(e=[])):(e=e||[]).push(l,k))}c&&(e=e||[]).push("style",c);var l=e;if(b.updateQueue=l)b.flags|=4}};var Ak=function(a,
b,c,d){c!==d&&(b.flags|=4)};var Jd=!1,X=!1,Fk="function"===typeof WeakSet?WeakSet:Set,l=null,zi=!1,T=null,za=!1,Mk=Math.ceil,Od=Sa.ReactCurrentDispatcher,Uf=Sa.ReactCurrentOwner,ca=Sa.ReactCurrentBatchConfig,p=0,O=null,H=null,U=0,ba=0,Ga=bb(0),L=0,Jc=null,ra=0,Md=0,Sf=0,Kc=null,ja=null,Of=0,Hf=Infinity,Ra=null,Ed=!1,xf=null,ib=null,Pd=!1,lb=null,Qd=0,Ic=0,Pf=null,Kd=-1,Ld=0;var Qk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||S.current)ha=!0;else{if(0===(a.lanes&c)&&0===(b.flags&
128))return ha=!1,wk(a,b,c);ha=0!==(a.flags&131072)?!0:!1}else ha=!1,D&&0!==(b.flags&1048576)&&yh(b,nd,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;Fd(a,b);a=b.pendingProps;var e=Nb(b,J.current);Sb(b,c);e=mf(null,b,d,a,e,c);var f=nf();b.flags|=1;"object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue=null,ea(d)?(f=!0,ld(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,ff(b),e.updater=Dd,b.stateNode=
e,e._reactInternals=b,uf(b,d,a,c),b=Af(null,b,d,!0,f,c)):(b.tag=0,D&&f&&Ue(b),aa(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{Fd(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=Uk(d);a=ya(d,a);switch(e){case 0:b=zf(null,b,d,a,c);break a;case 1:b=ri(null,b,d,a,c);break a;case 11:b=mi(null,b,d,a,c);break a;case 14:b=ni(null,b,d,ya(d.type,a),c);break a}throw Error(m(306,d,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),zf(a,b,d,e,c);
case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),ri(a,b,d,e,c);case 3:a:{si(b);if(null===a)throw Error(m(387));d=b.pendingProps;f=b.memoizedState;e=f.element;Fh(a,b);wd(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState=f,b.memoizedState=f,b.flags&256){e=Ub(Error(m(423)),b);b=ti(a,b,d,c,e);break a}else if(d!==e){e=
Ub(Error(m(424)),b);b=ti(a,b,d,c,e);break a}else for(fa=Ka(b.stateNode.containerInfo.firstChild),la=b,D=!0,wa=null,c=li(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Qb();if(d===e){b=Qa(a,b,c);break a}aa(a,b,d,c)}b=b.child}return b;case 5:return Ih(b),null===a&&Xe(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Qe(d,e)?g=null:null!==f&&Qe(d,f)&&(b.flags|=32),qi(a,b),aa(a,b,g,c),b.child;case 6:return null===a&&Xe(b),null;case 13:return ui(a,b,c);case 4:return gf(b,
b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Vb(b,null,d,c):aa(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),mi(a,b,d,e,c);case 7:return aa(a,b,b.pendingProps,c),b.child;case 8:return aa(a,b,b.pendingProps.children,c),b.child;case 12:return aa(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps;g=e.value;y(ud,d._currentValue);d._currentValue=g;if(null!==f)if(ua(f.value,g)){if(f.children===
e.children&&!S.current){b=Qa(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=Pa(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var p=l.pending;null===p?k.next=k:(k.next=p.next,p.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);df(f.return,c,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===
f.tag){g=f.return;if(null===g)throw Error(m(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);df(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}aa(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,Sb(b,c),e=qa(e),d=d(e),b.flags|=1,aa(a,b,d,c),b.child;case 14:return d=b.type,e=ya(d,b.pendingProps),e=ya(d.type,e),ni(a,b,d,e,c);case 15:return oi(a,
b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),Fd(a,b),b.tag=1,ea(d)?(a=!0,ld(b)):a=!1,Sb(b,c),ei(b,d,e),uf(b,d,e,c),Af(null,b,d,!0,a,c);case 19:return wi(a,b,c);case 22:return pi(a,b,c)}throw Error(m(156,b.tag));};var pa=function(a,b,c,d){return new Tk(a,b,c,d)},aj="function"===typeof reportError?reportError:function(a){console.error(a)};Ud.prototype.render=Xf.prototype.render=function(a){var b=this._internalRoot;if(null===b)throw Error(m(409));
Sd(a,b,null,null)};Ud.prototype.unmount=Xf.prototype.unmount=function(){var a=this._internalRoot;if(null!==a){this._internalRoot=null;var b=a.containerInfo;yb(function(){Sd(null,a,null,null)});b[Ja]=null}};Ud.prototype.unstable_scheduleHydration=function(a){if(a){var b=nl();a={blockedOn:null,target:a,priority:b};for(var c=0;c<Ya.length&&0!==b&&b<Ya[c].priority;c++);Ya.splice(c,0,a);0===c&&Hg(a)}};var Cj=function(a){switch(a.tag){case 3:var b=a.stateNode;if(b.current.memoizedState.isDehydrated){var c=
hc(b.pendingLanes);0!==c&&(xe(b,c|1),ia(b,P()),0===(p&6)&&(Hc(),db()))}break;case 13:yb(function(){var b=Oa(a,1);if(null!==b){var c=Z();xa(b,a,1,c)}}),Wf(a,1)}};var Gg=function(a){if(13===a.tag){var b=Oa(a,134217728);if(null!==b){var c=Z();xa(b,a,134217728,c)}Wf(a,134217728)}};var xj=function(a){if(13===a.tag){var b=hb(a),c=Oa(a,b);if(null!==c){var d=Z();xa(c,a,b,d)}Wf(a,b)}};var nl=function(){return z};var wj=function(a,b){var c=z;try{return z=a,b()}finally{z=c}};se=function(a,b,c){switch(b){case "input":le(a,
c);b=c.name;if("radio"===c.type&&null!=b){for(c=a;c.parentNode;)c=c.parentNode;c=c.querySelectorAll("input[name="+JSON.stringify(""+b)+'][type="radio"]');for(b=0;b<c.length;b++){var d=c[b];if(d!==a&&d.form===a.form){var e=Rc(d);if(!e)throw Error(m(90));jg(d);le(d,e)}}}break;case "textarea":og(a,c);break;case "select":b=c.value,null!=b&&Db(a,!!c.multiple,b,!1)}};(function(a,b,c){xg=a;yg=c})(Tf,function(a,b,c,d,e){var f=z,g=ca.transition;try{return ca.transition=null,z=1,a(b,c,d,e)}finally{z=f,ca.transition=
g,0===p&&Hc()}},yb);var ol={usingClientEntryPoint:!1,Events:[ec,Ib,Rc,ug,vg,Tf]};(function(a){a={bundleType:a.bundleType,version:a.version,rendererPackageName:a.rendererPackageName,rendererConfig:a.rendererConfig,overrideHookState:null,overrideHookStateDeletePath:null,overrideHookStateRenamePath:null,overrideProps:null,overridePropsDeletePath:null,overridePropsRenamePath:null,setErrorHandler:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:Sa.ReactCurrentDispatcher,findHostInstanceByFiber:Xk,
findFiberByHostInstance:a.findFiberByHostInstance||Yk,findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null,reconcilerVersion:"18.3.1"};if("undefined"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__)a=!1;else{var b=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(b.isDisabled||!b.supportsFiber)a=!0;else{try{Uc=b.inject(a),Ca=b}catch(c){}a=b.checkDCE?!0:!1}}return a})({findFiberByHostInstance:ob,bundleType:0,version:"18.3.1-next-f1338f8080-20240426",
rendererPackageName:"react-dom"});Q.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=ol;Q.createPortal=function(a,b){var c=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!Yf(b))throw Error(m(200));return Wk(a,b,null,c)};Q.createRoot=function(a,b){if(!Yf(a))throw Error(m(299));var c=!1,d="",e=aj;null!==b&&void 0!==b&&(!0===b.unstable_strictMode&&(c=!0),void 0!==b.identifierPrefix&&(d=b.identifierPrefix),void 0!==b.onRecoverableError&&(e=b.onRecoverableError));b=Vf(a,1,!1,null,null,
c,!1,d,e);a[Ja]=b.current;sc(8===a.nodeType?a.parentNode:a);return new Xf(b)};Q.findDOMNode=function(a){if(null==a)return null;if(1===a.nodeType)return a;var b=a._reactInternals;if(void 0===b){if("function"===typeof a.render)throw Error(m(188));a=Object.keys(a).join(",");throw Error(m(268,a));}a=Bg(b);a=null===a?null:a.stateNode;return a};Q.flushSync=function(a){return yb(a)};Q.hydrate=function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!0,c)};Q.hydrateRoot=function(a,b,c){if(!Yf(a))throw Error(m(405));
var d=null!=c&&c.hydratedSources||null,e=!1,f="",g=aj;null!==c&&void 0!==c&&(!0===c.unstable_strictMode&&(e=!0),void 0!==c.identifierPrefix&&(f=c.identifierPrefix),void 0!==c.onRecoverableError&&(g=c.onRecoverableError));b=Wi(b,null,a,1,null!=c?c:null,e,!1,f,g);a[Ja]=b.current;sc(a);if(d)for(a=0;a<d.length;a++)c=d[a],e=c._getVersion,e=e(c._source),null==b.mutableSourceEagerHydrationData?b.mutableSourceEagerHydrationData=[c,e]:b.mutableSourceEagerHydrationData.push(c,e);return new Ud(b)};Q.render=
function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!1,c)};Q.unmountComponentAtNode=function(a){if(!Vd(a))throw Error(m(40));return a._reactRootContainer?(yb(function(){Wd(null,null,a,!1,function(){a._reactRootContainer=null;a[Ja]=null})}),!0):!1};Q.unstable_batchedUpdates=Tf;Q.unstable_renderSubtreeIntoContainer=function(a,b,c,d){if(!Vd(c))throw Error(m(200));if(null==a||void 0===a._reactInternals)throw Error(m(38));return Wd(a,b,c,!1,d)};Q.version="18.3.1-next-f1338f8080-20240426"});
})();
+31
View File
@@ -0,0 +1,31 @@
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
(function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b,
e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1<l){for(var f=Array(l),k=0;k<l;k++)f[k]=arguments[k+2];d.children=f}if(a&&a.defaultProps)for(m in l=a.defaultProps,l)void 0===d[m]&&(d[m]=l[m]);return{$$typeof:y,type:a,key:c,ref:h,props:d,_owner:L.current}}function oa(a,b){return{$$typeof:y,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}
function M(a){return"object"===typeof a&&null!==a&&a.$$typeof===y}function pa(a){var b={"=":"=0",":":"=2"};return"$"+a.replace(/[=:]/g,function(a){return b[a]})}function N(a,b){return"object"===typeof a&&null!==a&&null!=a.key?pa(""+a.key):b.toString(36)}function B(a,b,e,m,d){var c=typeof a;if("undefined"===c||"boolean"===c)a=null;var h=!1;if(null===a)h=!0;else switch(c){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case y:case qa:h=!0}}if(h)return h=a,d=d(h),a=""===m?"."+
N(h,0):m,ca(d)?(e="",null!=a&&(e=a.replace(da,"$&/")+"/"),B(d,b,e,"",function(a){return a})):null!=d&&(M(d)&&(d=oa(d,e+(!d.key||h&&h.key===d.key?"":(""+d.key).replace(da,"$&/")+"/")+a)),b.push(d)),1;h=0;m=""===m?".":m+":";if(ca(a))for(var l=0;l<a.length;l++){c=a[l];var f=m+N(c,l);h+=B(c,b,e,f,d)}else if(f=x(a),"function"===typeof f)for(a=f.call(a),l=0;!(c=a.next()).done;)c=c.value,f=m+N(c,l++),h+=B(c,b,e,f,d);else if("object"===c)throw b=String(a),Error("Objects are not valid as a React child (found: "+
("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}function C(a,b,e){if(null==a)return a;var c=[],d=0;B(a,c,"","",function(a){return b.call(e,a,d++)});return c}function ra(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b});-1===a._status&&(a._status=
0,a._result=b)}if(1===a._status)return a._result.default;throw a._result;}function O(a,b){var e=a.length;a.push(b);a:for(;0<e;){var c=e-1>>>1,d=a[c];if(0<D(d,b))a[c]=b,a[e]=d,e=c;else break a}}function p(a){return 0===a.length?null:a[0]}function E(a){if(0===a.length)return null;var b=a[0],e=a.pop();if(e!==b){a[0]=e;a:for(var c=0,d=a.length,k=d>>>1;c<k;){var h=2*(c+1)-1,l=a[h],f=h+1,g=a[f];if(0>D(l,e))f<d&&0>D(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(f<d&&0>D(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-ha<ia?!1:!0}function R(a){G=a;H||(H=!0,I())}function T(a,b){A=ja(function(){a(v())},b)}function ka(a){throw Error("act(...) is not supported in production builds of React.");}var y=Symbol.for("react.element"),qa=Symbol.for("react.portal"),sa=Symbol.for("react.fragment"),
ta=Symbol.for("react.strict_mode"),ua=Symbol.for("react.profiler"),va=Symbol.for("react.provider"),wa=Symbol.for("react.context"),xa=Symbol.for("react.forward_ref"),ya=Symbol.for("react.suspense"),za=Symbol.for("react.memo"),Aa=Symbol.for("react.lazy"),V=Symbol.iterator,X={isMounted:function(a){return!1},enqueueForceUpdate:function(a,b,c){},enqueueReplaceState:function(a,b,c,m){},enqueueSetState:function(a,b,c,m){}},la=Object.assign,W={};w.prototype.isReactComponent={};w.prototype.setState=function(a,
b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};w.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};Y.prototype=w.prototype;var t=K.prototype=new Y;t.constructor=K;la(t,w.prototype);t.isPureReactComponent=!0;var ca=Array.isArray,aa=Object.prototype.hasOwnProperty,L={current:null},
ba={key:!0,ref:!0,__self:!0,__source:!0},da=/\/+/g,g={current:null},J={transition:null};if("object"===typeof performance&&"function"===typeof performance.now){var Ba=performance;var v=function(){return Ba.now()}}else{var ma=Date,Ca=ma.now();v=function(){return ma.now()-Ca}}var q=[],r=[],Da=1,n=null,k=3,F=!1,u=!1,z=!1,ja="function"===typeof setTimeout?setTimeout:null,ea="function"===typeof clearTimeout?clearTimeout:null,na="undefined"!==typeof setImmediate?setImmediate:null;"undefined"!==typeof navigator&&
void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var H=!1,G=null,A=-1,ia=5,ha=-1,U=function(){if(null!==G){var a=v();ha=a;var b=!0;try{b=G(!0,a)}finally{b?I():(H=!1,G=null)}}else H=!1};if("function"===typeof na)var I=function(){na(U)};else if("undefined"!==typeof MessageChannel){t=new MessageChannel;var Ea=t.port2;t.port1.onmessage=U;I=function(){Ea.postMessage(null)}}else I=function(){ja(U,0)};t={ReactCurrentDispatcher:g,
ReactCurrentOwner:L,ReactCurrentBatchConfig:J,Scheduler:{__proto__:null,unstable_ImmediatePriority:1,unstable_UserBlockingPriority:2,unstable_NormalPriority:3,unstable_IdlePriority:5,unstable_LowPriority:4,unstable_runWithPriority:function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3}var c=k;k=a;try{return b()}finally{k=c}},unstable_next:function(a){switch(k){case 1:case 2:case 3:var b=3;break;default:b=k}var c=k;k=b;try{return a()}finally{k=c}},unstable_scheduleCallback:function(a,
b,c){var e=v();"object"===typeof c&&null!==c?(c=c.delay,c="number"===typeof c&&0<c?e+c:e):c=e;switch(a){case 1:var d=-1;break;case 2:d=250;break;case 5:d=1073741823;break;case 4:d=1E4;break;default:d=5E3}d=c+d;a={id:Da++,callback:b,priorityLevel:a,startTime:c,expirationTime:d,sortIndex:-1};c>e?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125<a?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):
ia=0<a?Math.floor(1E3/a):5},unstable_Profiling:null}};c.Children={map:C,forEach:function(a,b,c){C(a,function(){b.apply(this,arguments)},c)},count:function(a){var b=0;C(a,function(){b++});return b},toArray:function(a){return C(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};c.Component=w;c.Fragment=sa;c.Profiler=ua;c.PureComponent=K;c.StrictMode=ta;c.Suspense=ya;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=
t;c.act=ka;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var e=la({},a.props),d=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=L.current);void 0!==b.key&&(d=""+b.key);if(a.type&&a.type.defaultProps)var l=a.type.defaultProps;for(f in b)aa.call(b,f)&&!ba.hasOwnProperty(f)&&(e[f]=void 0===b[f]&&void 0!==l?l[f]:b[f])}var f=arguments.length-2;if(1===f)e.children=c;else if(1<f){l=
Array(f);for(var g=0;g<f;g++)l[g]=arguments[g+2];e.children=l}return{$$typeof:y,type:a.type,key:d,ref:k,props:e,_owner:h}};c.createContext=function(a){a={$$typeof:wa,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:va,_context:a};return a.Consumer=a};c.createElement=Z;c.createFactory=function(a){var b=Z.bind(null,a);b.type=a;return b};c.createRef=function(){return{current:null}};c.forwardRef=function(a){return{$$typeof:xa,
render:a}};c.isValidElement=M;c.lazy=function(a){return{$$typeof:Aa,_payload:{_status:-1,_result:a},_init:ra}};c.memo=function(a,b){return{$$typeof:za,type:a,compare:void 0===b?null:b}};c.startTransition=function(a,b){b=J.transition;J.transition={};try{a()}finally{J.transition=b}};c.unstable_act=ka;c.useCallback=function(a,b){return g.current.useCallback(a,b)};c.useContext=function(a){return g.current.useContext(a)};c.useDebugValue=function(a,b){};c.useDeferredValue=function(a){return g.current.useDeferredValue(a)};
c.useEffect=function(a,b){return g.current.useEffect(a,b)};c.useId=function(){return g.current.useId()};c.useImperativeHandle=function(a,b,c){return g.current.useImperativeHandle(a,b,c)};c.useInsertionEffect=function(a,b){return g.current.useInsertionEffect(a,b)};c.useLayoutEffect=function(a,b){return g.current.useLayoutEffect(a,b)};c.useMemo=function(a,b){return g.current.useMemo(a,b)};c.useReducer=function(a,b,c){return g.current.useReducer(a,b,c)};c.useRef=function(a){return g.current.useRef(a)};
c.useState=function(a){return g.current.useState(a)};c.useSyncExternalStore=function(a,b,c){return g.current.useSyncExternalStore(a,b,c)};c.useTransition=function(){return g.current.useTransition()};c.version="18.3.1"});
})();
+29
View File
@@ -0,0 +1,29 @@
name: barev-dev
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: barev
POSTGRES_PASSWORD: barev
POSTGRES_DB: barev
TZ: Asia/Yerevan
ports: ["5434:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U barev"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
ports: ["6380:6379"]
# S3-совместимое хранилище. Бакет создаёт API при старте.
s3:
image: chrislusf/seaweedfs:latest
command: server -dir=/data -s3 -s3.port=9000 -s3.config=/etc/seaweed/s3.json -master.volumeSizeLimitMB=256
ports: ["9000:9000"]
volumes:
- s3data:/data
- ./seaweed-s3.json:/etc/seaweed/s3.json:ro
volumes:
pgdata:
s3data:
+9
View File
@@ -0,0 +1,9 @@
{
"identities": [
{
"name": "barev",
"credentials": [{ "accessKey": "barev", "secretKey": "barev-secret" }],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "barev-club",
"private": true,
"type": "module",
"packageManager": "pnpm@12.8.1",
"engines": { "node": ">=22" },
"scripts": {
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"lint": "turbo run lint",
"infra:up": "docker compose -f infra/docker-compose.dev.yml up -d",
"infra:down": "docker compose -f infra/docker-compose.dev.yml down",
"db:migrate": "pnpm --filter @barev/db migrate",
"db:seed": "pnpm --filter @barev/db seed",
"db:reset": "pnpm --filter @barev/db reset",
"i18n:check": "pnpm --filter @barev/i18n check",
"i18n:export": "pnpm --filter @barev/i18n export",
"i18n:import": "pnpm --filter @barev/i18n import",
"dev:initdata": "pnpm --filter @barev/api dev:initdata"
},
"devDependencies": {
"turbo": "^2.11.9",
"typescript": "~5.9.3",
"@types/node": "^24.0.0"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@barev/core",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": { ".": "./src/index.ts" },
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.3.0"
},
"devDependencies": {
"vitest": "^4.1.0"
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { Locale } from './types.ts';
/** Кандидат на заказ: частный исполнитель или компания-партнёр (PRO-7) */
export interface Candidate {
kind: 'provider' | 'company';
id: string;
name: string;
languages: Locale[];
districtIds: string[];
/** заказов на этот день уже в работе */
load: number;
/** средняя оценка 0–5, null — ещё нет оценок */
rating: number | null;
/** свободен в нужное время (по графику и занятости) */
free: boolean;
onShift: boolean;
}
export interface OrderNeeds {
locale: Locale;
districtId: string | null;
isOnline: boolean;
}
export interface RankedCandidate extends Candidate {
score: number;
reasons: ('language' | 'district' | 'onShift' | 'lowLoad')[];
}
/** Веса подсказки (ADM-3). Язык клиента важнее района, район важнее загрузки. */
export const ASSIGNMENT_WEIGHTS = {
language: 100,
district: 50,
onShift: 10,
perLoad: -15,
perRatingPoint: 4,
} as const;
/**
* Ранжирует свободных кандидатов. Занятые не попадают в список вовсе.
* Сортировка стабильная: при равном счёте — по имени.
*/
export function rankCandidates(candidates: Candidate[], needs: OrderNeeds): RankedCandidate[] {
const w = ASSIGNMENT_WEIGHTS;
return candidates
.filter((c) => c.free)
.map((c) => {
const reasons: RankedCandidate['reasons'] = [];
let score = 0;
if (c.languages.includes(needs.locale)) {
score += w.language;
reasons.push('language');
}
if (!needs.isOnline && needs.districtId && c.districtIds.includes(needs.districtId)) {
score += w.district;
reasons.push('district');
}
if (c.onShift) {
score += w.onShift;
reasons.push('onShift');
}
if (c.load === 0) reasons.push('lowLoad');
score += w.perLoad * c.load;
score += w.perRatingPoint * (c.rating ?? 4);
return { ...c, score, reasons };
})
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
}
/** Лучший кандидат — для кнопки «Назначить: <имя>» */
export function suggestCandidate(candidates: Candidate[], needs: OrderNeeds): RankedCandidate | null {
return rankCandidates(candidates, needs)[0] ?? null;
}
export function averageRating(sum: number, count: number): number | null {
return count > 0 ? Math.round((sum / count) * 10) / 10 : null;
}
+31
View File
@@ -0,0 +1,31 @@
import { HOUR } from './time.ts';
export interface CancelPolicyInput {
scheduledAt: Date;
now: Date;
/** порог бесплатной отмены, часы (настройка cancel.freeHours) */
freeHours: number;
/** удержание при поздней отмене, % (Service.lateCancelFeePct) */
lateFeePct: number;
totalPrice: number;
}
export type CancelPolicy =
| { kind: 'free'; fee: 0 }
/** клиент может отменить сам, с удержанием */
| { kind: 'late'; fee: number; feePct: number }
/** услуга уже началась или прошла — только через оператора */
| { kind: 'operator_only' };
/** ORD-6 */
export function cancelPolicy(i: CancelPolicyInput): CancelPolicy {
const left = i.scheduledAt.getTime() - i.now.getTime();
if (left <= 0) return { kind: 'operator_only' };
if (left >= i.freeHours * HOUR) return { kind: 'free', fee: 0 };
return { kind: 'late', fee: Math.floor((i.totalPrice * i.lateFeePct) / 100), feePct: i.lateFeePct };
}
/** Перенос: бесплатно только вне порога; внутри — через оператора */
export function canRescheduleSelf(scheduledAt: Date, now: Date, freeHours: number): boolean {
return scheduledAt.getTime() - now.getTime() >= freeHours * HOUR;
}
+210
View File
@@ -0,0 +1,210 @@
import type { ParamsSchema } from './params.ts';
import type { Pricing } from './pricing.ts';
import type { SlotConfig } from './slots.ts';
import type { CategoryCode, ProviderRole, ServiceFlow } from './types.ts';
/**
* Состав каталога из PRD (раздел 2) — «только эти, ничего не добавлять».
* Цены здесь — заглушки для seed; реальные задаёт владелец в админке.
* `stage` — этап, в котором услуга становится доступной для заказа.
*/
export interface CatalogService {
code: string;
category: CategoryCode;
flow: ServiceFlow;
providerRole: ProviderRole;
stage: 1 | 2 | 3;
isOnline: boolean;
pricing: Pricing;
paramsSchema: ParamsSchema;
slotConfig: SlotConfig;
checklist: { code: string; requiresPhoto?: boolean }[];
}
const DAY_WINDOW: SlotConfig = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
const fixed = (price: number, minutes: number): Pricing => ({ kind: 'fixed', price, minutes });
export const CATALOG: CatalogService[] = [
{
code: 'cleaning',
category: 'HOME',
flow: 'CLEANING',
providerRole: 'CLEANER',
stage: 1,
isOnline: false,
pricing: {
kind: 'rooms',
variantParam: 'cleaningType',
roomsParam: 'rooms',
tiers: {
regular: { base: 15000, perRoom: 4000, baseMinutes: 120, perRoomMinutes: 45 },
general: { base: 25000, perRoom: 7000, baseMinutes: 210, perRoomMinutes: 75 },
renovation: { base: 35000, perRoom: 10000, baseMinutes: 300, perRoomMinutes: 90 },
},
},
paramsSchema: {
fields: [
{ key: 'cleaningType', type: 'select', options: ['regular', 'general', 'renovation'], default: 'regular', required: true },
{ key: 'rooms', type: 'int', min: 1, max: 6, default: 2, required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [
{ code: 'kitchen' },
{ code: 'bathroom' },
{ code: 'rooms' },
{ code: 'floors' },
{ code: 'photoReport', requiresPhoto: true },
],
},
{
code: 'repair',
category: 'HOME',
flow: 'REPAIR',
providerRole: 'HANDYMAN',
stage: 2,
isOnline: false,
pricing: fixed(8000, 90),
paramsSchema: {
fields: [
{ key: 'repairType', type: 'select', options: ['plumbing', 'electrical', 'minor'], required: true },
{ key: 'problem', type: 'text', maxLength: 1000, required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [{ code: 'diagnostics' }, { code: 'estimate' }, { code: 'repair' }, { code: 'photo', requiresPhoto: true }],
},
{
code: 'transfer',
category: 'HOME',
flow: 'TRANSFER',
providerRole: 'DRIVER',
// решение владельца: трансфер отложен, сценарий и state machine готовы
stage: 2,
isOnline: false,
pricing: fixed(10000, 60),
paramsSchema: {
fields: [
{ key: 'direction', type: 'select', options: ['fromAirport', 'toAirport'], required: true },
{ key: 'flight', type: 'text', maxLength: 16, required: true },
{ key: 'passengers', type: 'int', min: 1, max: 7, default: 1, required: true },
{ key: 'luggage', type: 'int', min: 0, max: 10, default: 1, required: true },
],
},
slotConfig: { dayStartMinute: 0, dayEndMinute: 1440, stepMinutes: 15, leadHours: 6, horizonDays: 30 },
checklist: [{ code: 'luggage' }, { code: 'address' }],
},
{
code: 'social_card',
category: 'DOCS',
flow: 'DOCUMENTS',
providerRole: 'DOCS_AGENT',
stage: 2,
isOnline: false,
pricing: fixed(12000, 60),
paramsSchema: { fields: [{ key: 'forWhom', type: 'select', options: ['self', 'family'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [],
},
{
code: 'passport_translation',
category: 'DOCS',
flow: 'DOCUMENTS',
providerRole: 'DOCS_AGENT',
stage: 2,
isOnline: false,
pricing: fixed(9000, 60),
paramsSchema: { fields: [{ key: 'forWhom', type: 'select', options: ['self', 'family'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [],
},
{
code: 'residence_permit',
category: 'DOCS',
flow: 'DOCUMENTS',
providerRole: 'DOCS_AGENT',
stage: 2,
isOnline: false,
pricing: fixed(45000, 60),
paramsSchema: { fields: [{ key: 'forWhom', type: 'select', options: ['self', 'family'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [],
},
{
code: 'lang_armenian',
category: 'LEARNING',
flow: 'LANGUAGE',
providerRole: 'TEACHER',
stage: 2,
isOnline: true,
pricing: fixed(40000, 60),
paramsSchema: {
fields: [
{ key: 'level', type: 'select', options: ['A1', 'A2', 'B1', 'B2'], required: true },
{ key: 'format', type: 'select', options: ['online', 'offline'], required: true },
{ key: 'lessons', type: 'select', options: ['4', '8', '12'], required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [{ code: 'review' }, { code: 'newTopic' }, { code: 'homework' }],
},
{
code: 'lang_english',
category: 'LEARNING',
flow: 'LANGUAGE',
providerRole: 'TEACHER',
stage: 2,
isOnline: true,
pricing: fixed(40000, 60),
paramsSchema: {
fields: [
{ key: 'level', type: 'select', options: ['A1', 'A2', 'B1', 'B2', 'C1'], required: true },
{ key: 'format', type: 'select', options: ['online', 'offline'], required: true },
{ key: 'lessons', type: 'select', options: ['4', '8', '12'], required: true },
],
},
slotConfig: DAY_WINDOW,
checklist: [{ code: 'review' }, { code: 'newTopic' }, { code: 'homework' }],
},
{
code: 'career',
category: 'LEARNING',
flow: 'CAREER',
providerRole: 'CAREER_CONSULTANT',
stage: 2,
isOnline: true,
pricing: fixed(20000, 60),
paramsSchema: { fields: [{ key: 'topic', type: 'select', options: ['resume', 'jobSearch'], required: true }] },
slotConfig: DAY_WINDOW,
checklist: [{ code: 'resume' }, { code: 'plan' }, { code: 'materials' }],
},
];
/** Скидка Barev+ по направлениям — заглушки */
export const DEFAULT_CATEGORY_DISCOUNTS: Record<CategoryCode, number> = { HOME: 10, DOCS: 10, LEARNING: 15, PERKS: 0 };
/** 12 административных районов Еревана; названия — i18n districts.<code> */
export const YEREVAN_DISTRICTS = [
'ajapnyak',
'arabkir',
'avan',
'davtashen',
'erebuni',
'kanaker_zeytun',
'kentron',
'malatia_sebastia',
'nork_marash',
'nor_nork',
'nubarashen',
'shengavit',
] as const;
/** Роль исполнителя → какие сценарии он ведёт (для бота исполнителей) */
export const ROLE_FLOWS: Record<ProviderRole, ServiceFlow[]> = {
CLEANER: ['CLEANING'],
HANDYMAN: ['REPAIR'],
DRIVER: ['TRANSFER'],
TEACHER: ['LANGUAGE'],
CAREER_CONSULTANT: ['CAREER'],
DOCS_AGENT: ['DOCUMENTS'],
};
+71
View File
@@ -0,0 +1,71 @@
import { isLocale, type Locale } from './types.ts';
/**
* Payload для t.me/<bot>?start=<payload> (LND-3, ONB-1).
* Telegram ограничивает его 64 символами из [A-Za-z0-9_-], поэтому формат компактный:
*
* l-ru_a-AGENCY1 язык и код агентства
* s-k3j9x2 короткий токен; язык, агентство и UTM лежат в БД (StartToken)
*
* Части разделены "_", ключ и значение — "-". Неизвестные части игнорируются.
*/
export interface StartPayload {
locale?: Locale;
agency?: string;
token?: string;
}
export const START_PAYLOAD_MAX = 64;
const SAFE = /^[A-Za-z0-9]+$/;
export function normalizeAgencyCode(code: string): string | null {
const c = code.trim().toUpperCase();
return SAFE.test(c) && c.length <= 32 ? c : null;
}
export function encodeStartPayload(p: StartPayload): string {
const parts: string[] = [];
if (p.token) {
if (!SAFE.test(p.token)) throw new Error('Bad token');
parts.push(`s-${p.token}`);
}
if (p.locale) parts.push(`l-${p.locale}`);
if (p.agency) {
const a = normalizeAgencyCode(p.agency);
if (a) parts.push(`a-${a}`);
}
const out = parts.join('_');
if (out.length > START_PAYLOAD_MAX) throw new Error('Start payload too long');
return out;
}
export function decodeStartPayload(raw: string | undefined | null): StartPayload {
const out: StartPayload = {};
if (!raw) return out;
for (const part of raw.slice(0, START_PAYLOAD_MAX).split('_')) {
const [key, value] = [part.slice(0, 1), part.slice(2)];
if (part[1] !== '-' || !value) continue;
if (key === 'l' && isLocale(value)) out.locale = value;
else if (key === 'a') {
const a = normalizeAgencyCode(value);
if (a) out.agency = a;
} else if (key === 's' && SAFE.test(value)) out.token = value;
}
return out;
}
export const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
export type Utm = Partial<Record<(typeof UTM_KEYS)[number], string>>;
export function pickUtm(params: URLSearchParams): Utm {
const utm: Utm = {};
for (const k of UTM_KEYS) {
const v = params.get(k);
if (v) utm[k] = v.slice(0, 120);
}
return utm;
}
export function botLink(username: string, payload: string): string {
return `https://t.me/${username}${payload ? `?start=${payload}` : ''}`;
}
+269
View File
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest';
import { rankCandidates, suggestCandidate, type Candidate } from './assignment.ts';
import { cancelPolicy, canRescheduleSelf } from './cancellation.ts';
import { CATALOG } from './catalog.ts';
import { decodeStartPayload, encodeStartPayload, pickUtm } from './deeplink.ts';
import { agencyCommission, decideGift, discountFor, isAgencyAttributionActive, membershipLevel, payoutFor } from './membership.ts';
import { defaultParams, validateParams } from './params.ts';
import { fromPrice, PricingError, quote } from './pricing.ts';
import { checkPromo, combineDiscounts, type PromoLike } from './promo.ts';
import { DEFAULT_SETTINGS, resolveSettings } from './settings.ts';
import { daySlots, type ProviderAvailability } from './slots.ts';
import { fromYerevan, HOUR, toYerevan, yerevanDateKey } from './time.ts';
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
describe('pricing (ORD-4)', () => {
it('считает базовую цену по типу и комнатам', () => {
expect(quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 })).toEqual({
basePrice: 15000,
discountPct: 0,
discountAmount: 0,
totalPrice: 15000,
durationMinutes: 120,
});
expect(quote(cleaning.pricing, { cleaningType: 'general', rooms: 3 }).basePrice).toBe(25000 + 2 * 7000);
});
it('вычитает скидку Barev+ и округляет её вниз', () => {
const q = quote({ kind: 'fixed', price: 15005, minutes: 60 }, {}, 10);
expect(q.discountAmount).toBe(1500);
expect(q.totalPrice).toBe(13505);
expect(q.basePrice - q.discountAmount).toBe(q.totalPrice);
});
it('отклоняет неизвестный вариант, плохие комнаты и скидку вне 0–100', () => {
expect(() => quote(cleaning.pricing, { cleaningType: 'deluxe', rooms: 1 })).toThrow(PricingError);
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 0 })).toThrow(PricingError);
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 }, 101)).toThrow(PricingError);
});
it('«от N ֏» — минимальный тариф', () => {
expect(fromPrice(cleaning.pricing)).toBe(15000);
});
});
describe('params (ORD-2)', () => {
it('дефолты и валидация', () => {
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2 });
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', junk: 1 })).toEqual({
ok: true,
values: { cleaningType: 'general', rooms: 3 },
});
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9 })).toEqual({
ok: false,
errors: [
{ key: 'cleaningType', code: 'invalid' },
{ key: 'rooms', code: 'invalid' },
],
});
expect(validateParams(cleaning.paramsSchema, {})).toMatchObject({ ok: false });
});
});
describe('time (Asia/Yerevan)', () => {
it('UTC+4 без летнего времени', () => {
const d = fromYerevan(2026, 7, 1, 9 * 60);
expect(d.toISOString()).toBe('2026-07-01T05:00:00.000Z');
expect(toYerevan(new Date('2026-01-15T21:30:00Z'))).toMatchObject({ day: 16, hour: 1, minute: 30, weekday: 4 });
expect(yerevanDateKey(new Date('2026-12-31T20:00:00Z'))).toBe('2027-01-01');
});
});
describe('slots (ORD-3)', () => {
// 2026-10-19 — понедельник
const now = fromYerevan(2026, 10, 18, 12 * 60);
const fullDay = [{ weekday: 0, startMinute: 9 * 60, endMinute: 20 * 60 }];
const config = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
it('нет исполнителей — нет слотов', () => {
expect(daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [], unassigned: [] })).toEqual([]);
});
it('слоты по окну услуги, длительности и графику', () => {
const p: ProviderAvailability = { id: 'a', schedule: fullDay, busy: [] };
const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [p], unassigned: [] });
// 9:00 … 18:00 (последний слот заканчивается в 20:00)
expect(slots).toHaveLength(10);
expect(toYerevan(slots[0]!.start).hour).toBe(9);
expect(toYerevan(slots.at(-1)!.start).hour).toBe(18);
});
it('занятость исполнителя и неназначенные заказы уменьшают мощность', () => {
const busy = [{ start: fromYerevan(2026, 10, 19, 10 * 60), end: fromYerevan(2026, 10, 19, 12 * 60) }];
const a: ProviderAvailability = { id: 'a', schedule: fullDay, busy };
const b: ProviderAvailability = { id: 'b', schedule: fullDay, busy: [] };
const unassigned = [{ start: fromYerevan(2026, 10, 19, 14 * 60), end: fromYerevan(2026, 10, 19, 15 * 60) }];
const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 60, now, providers: [a, b], unassigned });
const cap = (h: number) => slots.find((s) => toYerevan(s.start).hour === h)?.capacity ?? 0;
expect(cap(9)).toBe(2);
expect(cap(10)).toBe(1);
expect(cap(14)).toBe(1);
});
it('учитывает минимальный запас до начала', () => {
const p: ProviderAvailability = { id: 'a', schedule: [{ weekday: 6, startMinute: 0, endMinute: 1440 }], busy: [] };
// сегодня воскресенье 12:00 → первый слот не раньше 15:00
const slots = daySlots({ dateKey: '2026-10-18', config, durationMinutes: 60, now, providers: [p], unassigned: [] });
expect(toYerevan(slots[0]!.start).hour).toBe(15);
});
});
describe('assignment (ADM-3)', () => {
const base: Omit<Candidate, 'id' | 'name'> = {
kind: 'provider',
languages: ['ru'],
districtIds: [],
load: 0,
rating: 4.5,
free: true,
onShift: true,
};
it('язык клиента важнее района, район — важнее загрузки', () => {
const c: Candidate[] = [
{ ...base, id: '1', name: 'A', languages: ['ru'], districtIds: ['kentron'] },
{ ...base, id: '2', name: 'B', languages: ['en'], districtIds: ['kentron'] },
{ ...base, id: '3', name: 'C', languages: ['en'], districtIds: [], load: 0 },
];
const ranked = rankCandidates(c, { locale: 'en', districtId: 'kentron', isOnline: false });
expect(ranked.map((r) => r.id)).toEqual(['2', '3', '1']);
expect(ranked[0]!.reasons).toEqual(expect.arrayContaining(['language', 'district']));
});
it('занятые исключаются, загрузка снижает приоритет', () => {
const c: Candidate[] = [
{ ...base, id: '1', name: 'A', free: false },
{ ...base, id: '2', name: 'B', load: 3 },
{ ...base, id: '3', name: 'C', load: 0 },
];
expect(suggestCandidate(c, { locale: 'ru', districtId: null, isOnline: false })?.id).toBe('3');
expect(rankCandidates(c, { locale: 'ru', districtId: null, isOnline: false })).toHaveLength(2);
});
it('нет свободных — нет подсказки', () => {
expect(suggestCandidate([{ ...base, id: '1', name: 'A', free: false }], { locale: 'ru', districtId: null, isOnline: false })).toBeNull();
});
});
describe('cancellation (ORD-6)', () => {
const now = new Date('2026-10-17T10:00:00Z');
const at = (h: number) => new Date(now.getTime() + h * HOUR);
it('за 24 ч и раньше — бесплатно', () => {
expect(cancelPolicy({ scheduledAt: at(24), now, freeHours: 24, lateFeePct: 30, totalPrice: 20000 })).toEqual({ kind: 'free', fee: 0 });
});
it('позже — удержание 30%', () => {
expect(cancelPolicy({ scheduledAt: at(23), now, freeHours: 24, lateFeePct: 30, totalPrice: 20001 })).toEqual({
kind: 'late',
fee: 6000,
feePct: 30,
});
});
it('после начала — только через оператора', () => {
expect(cancelPolicy({ scheduledAt: at(-1), now, freeHours: 24, lateFeePct: 30, totalPrice: 1 })).toEqual({ kind: 'operator_only' });
});
it('перенос сам — только вне порога', () => {
expect(canRescheduleSelf(at(25), now, 24)).toBe(true);
expect(canRescheduleSelf(at(5), now, 24)).toBe(false);
});
});
describe('deep-link payload (LND-3 / ONB-1)', () => {
it('кодирует и декодирует язык и агентство', () => {
const p = encodeStartPayload({ locale: 'hy', agency: 'domik1' });
expect(p).toBe('l-hy_a-DOMIK1');
expect(decodeStartPayload(p)).toEqual({ locale: 'hy', agency: 'DOMIK1' });
});
it('токен для UTM', () => {
expect(decodeStartPayload(encodeStartPayload({ token: 'k3j9x2', locale: 'en' }))).toEqual({ token: 'k3j9x2', locale: 'en' });
});
it('игнорирует мусор и неизвестный язык', () => {
expect(decodeStartPayload('l-fr_a-<script>_zzz_x-1')).toEqual({});
expect(decodeStartPayload(undefined)).toEqual({});
});
it('укладывается в лимит Telegram', () => {
const p = encodeStartPayload({ token: 'abcdefghij', locale: 'zh', agency: 'A'.repeat(32) });
expect(p.length).toBeLessThanOrEqual(64);
expect(p).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('достаёт UTM из URL', () => {
expect(pickUtm(new URLSearchParams('utm_source=qr&utm_campaign=oct&x=1'))).toEqual({ utm_source: 'qr', utm_campaign: 'oct' });
});
});
describe('Barev+ и агентства (ONB-4, SUB-3, ADM-8)', () => {
const now = new Date('2026-10-17T10:00:00Z');
it('подарочный месяц — один раз', () => {
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-16T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
});
it('подарок продлевает действующую подписку', () => {
const endsAt = new Date('2026-10-20T00:00:00Z');
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: endsAt });
expect(r.ok && r.startsAt).toEqual(endsAt);
});
it('уровень членства и скидка', () => {
const subs = [{ startsAt: new Date('2026-10-01'), endsAt: new Date('2026-11-01'), status: 'ACTIVE' as const }];
expect(membershipLevel(subs, now)).toBe('BAREV_PLUS');
expect(membershipLevel(subs, new Date('2026-11-02'))).toBe('BAREV');
expect(membershipLevel([{ ...subs[0]!, status: 'CANCELLED' }], now)).toBe('BAREV');
expect(discountFor('BAREV_PLUS', 10)).toBe(10);
expect(discountFor('BAREV', 10)).toBe(0);
});
it('клиент закреплён за агентством 12 месяцев, вознаграждение 10%', () => {
const attached = new Date('2025-11-01T00:00:00Z');
expect(isAgencyAttributionActive(attached, now, 12)).toBe(true);
expect(isAgencyAttributionActive(attached, new Date('2026-11-01T00:00:01Z'), 12)).toBe(false);
expect(isAgencyAttributionActive(null, now, 12)).toBe(false);
expect(agencyCommission(18999, 10)).toBe(1899);
});
it('выплата исполнителю: фикс или процент (PRO-8)', () => {
expect(payoutFor(20000, { type: 'FIXED', value: 9000 })).toBe(9000);
expect(payoutFor(20000, { type: 'PERCENT', value: 70 })).toBe(14000);
});
});
describe('promo codes', () => {
const now = new Date('2026-10-17T10:00:00Z');
const p: PromoLike = { kind: 'DISCOUNT', active: true, maxUses: 2, usedCount: 0, validFrom: null, validUntil: null };
it('проверяет активность, сроки, лимиты и повторное использование', () => {
expect(checkPromo(p, now, false)).toEqual({ ok: true });
expect(checkPromo({ ...p, active: false }, now, false)).toEqual({ ok: false, reason: 'INACTIVE' });
expect(checkPromo({ ...p, validFrom: new Date('2026-11-01') }, now, false)).toEqual({ ok: false, reason: 'NOT_STARTED' });
expect(checkPromo({ ...p, validUntil: new Date('2026-10-01') }, now, false)).toEqual({ ok: false, reason: 'EXPIRED' });
expect(checkPromo({ ...p, usedCount: 2 }, now, false)).toEqual({ ok: false, reason: 'EXHAUSTED' });
expect(checkPromo(p, now, true)).toEqual({ ok: false, reason: 'ALREADY_USED_BY_CLIENT' });
});
it('скидки не суммируются', () => {
expect(combineDiscounts(10, 15)).toBe(15);
expect(combineDiscounts(10, null)).toBe(10);
});
});
describe('settings', () => {
it('дефолты из PRD и решений владельца', () => {
expect(DEFAULT_SETTINGS['cancel.freeHours']).toBe(24);
expect(DEFAULT_SETTINGS['agency.attributionMonths']).toBe(12);
expect(DEFAULT_SETTINGS['agency.defaultCommissionPct']).toBe(10);
expect(DEFAULT_SETTINGS['files.retentionDays']).toBe(90);
});
it('битые значения заменяются дефолтом, неизвестные ключи игнорируются', () => {
const s = resolveSettings([
{ key: 'cancel.freeHours', value: 48 },
{ key: 'offer.acceptTimeoutMinutes', value: 'abc' },
{ key: 'nope', value: 1 },
]);
expect(s['cancel.freeHours']).toBe(48);
expect(s['offer.acceptTimeoutMinutes']).toBe(15);
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { ServiceFlow } from '../types.ts';
import type { FlowDef } from './types.ts';
const OPS = ['ADMIN', 'SYSTEM'] as const;
/** назначение: админ, система (принятие предложения) или диспетчер компании */
const ASSIGN = ['ADMIN', 'SYSTEM', 'DISPATCHER'] as const;
const PRO = ['PROVIDER', 'ADMIN'] as const;
/** Клининг: Новый → Назначен → В пути → Уборка → Готово */
const CLEANING: FlowDef = {
flow: 'CLEANING',
steps: ['NEW', 'ASSIGNED', 'EN_ROUTE', 'CLEANING', 'DONE'],
initial: 'NEW',
assigned: 'ASSIGNED',
done: ['DONE'],
clientCancellable: ['NEW', 'ASSIGNED'],
checklistAt: ['CLEANING'],
transitions: [
{ from: 'NEW', to: 'ASSIGNED', by: ASSIGN },
{ from: 'ASSIGNED', to: 'NEW', by: OPS },
{ from: 'ASSIGNED', to: 'EN_ROUTE', by: PRO, action: 'depart' },
{ from: 'EN_ROUTE', to: 'CLEANING', by: PRO, action: 'startCleaning' },
{ from: 'CLEANING', to: 'DONE', by: PRO, action: 'finishCleaning', guards: ['checklistComplete'] },
],
events: [{ code: 'arrived', at: ['EN_ROUTE'], by: PRO }],
};
/** Ремонт: Новый → Назначен → На месте → Смета согласована → Ремонт → Готово */
const REPAIR: FlowDef = {
flow: 'REPAIR',
steps: ['NEW', 'ASSIGNED', 'ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING', 'DONE'],
initial: 'NEW',
assigned: 'ASSIGNED',
done: ['DONE'],
clientCancellable: ['NEW', 'ASSIGNED'],
checklistAt: ['ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING'],
transitions: [
{ from: 'NEW', to: 'ASSIGNED', by: ASSIGN },
{ from: 'ASSIGNED', to: 'NEW', by: OPS },
{ from: 'ASSIGNED', to: 'ON_SITE', by: PRO, action: 'arrived' },
{ from: 'ON_SITE', to: 'ESTIMATE_APPROVED', by: ['CLIENT', 'SYSTEM'], guards: ['estimateAccepted'] },
{ from: 'ESTIMATE_APPROVED', to: 'REPAIRING', by: PRO, action: 'startWork', guards: ['estimateAccepted'] },
{ from: 'REPAIRING', to: 'DONE', by: PRO, action: 'closeRepair', guards: ['checklistComplete'] },
],
events: [],
};
/** Трансфер: Новый → Назначен → Водитель ждёт → В пути → Завершён */
const TRANSFER: FlowDef = {
flow: 'TRANSFER',
steps: ['NEW', 'ASSIGNED', 'DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'COMPLETED'],
initial: 'NEW',
assigned: 'ASSIGNED',
done: ['COMPLETED'],
clientCancellable: ['NEW', 'ASSIGNED'],
checklistAt: ['RIDE_IN_PROGRESS'],
transitions: [
{ from: 'NEW', to: 'ASSIGNED', by: ASSIGN },
{ from: 'ASSIGNED', to: 'NEW', by: OPS },
{ from: 'ASSIGNED', to: 'DRIVER_WAITING', by: PRO, action: 'atPickup' },
{ from: 'DRIVER_WAITING', to: 'RIDE_IN_PROGRESS', by: PRO, action: 'passengerIn' },
{ from: 'RIDE_IN_PROGRESS', to: 'COMPLETED', by: PRO, action: 'finishRide', guards: ['checklistComplete'] },
],
events: [],
};
/** Языки: Записан → Урок N запланирован → Идёт урок → Урок завершён → … → Завершён */
const LANGUAGE: FlowDef = {
flow: 'LANGUAGE',
steps: ['ENROLLED', 'LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'LESSON_DONE', 'COMPLETED'],
initial: 'ENROLLED',
assigned: null,
done: ['COMPLETED'],
clientCancellable: ['ENROLLED'],
checklistAt: ['LESSON_IN_PROGRESS'],
transitions: [
{ from: 'ENROLLED', to: 'LESSON_SCHEDULED', by: PRO, action: 'scheduleLesson', guards: ['lessonsRemaining'] },
{ from: 'LESSON_SCHEDULED', to: 'LESSON_IN_PROGRESS', by: PRO, action: 'startLesson' },
{ from: 'LESSON_IN_PROGRESS', to: 'LESSON_DONE', by: PRO, action: 'finishLesson', guards: ['checklistComplete'] },
{ from: 'LESSON_DONE', to: 'LESSON_SCHEDULED', by: PRO, action: 'scheduleLesson', guards: ['lessonsRemaining'] },
{ from: 'LESSON_DONE', to: 'COMPLETED', by: OPS, guards: ['allLessonsDone'] },
],
events: [{ code: 'openLesson', at: ['LESSON_SCHEDULED'], by: PRO }],
};
/** Карьера: Новый → Консультант назначен → Резюме получено → Встреча → План отправлен */
const CAREER: FlowDef = {
flow: 'CAREER',
steps: ['NEW', 'CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'MEETING', 'PLAN_SENT'],
initial: 'NEW',
assigned: 'CONSULTANT_ASSIGNED',
done: ['PLAN_SENT'],
clientCancellable: ['NEW', 'CONSULTANT_ASSIGNED'],
checklistAt: ['MEETING'],
transitions: [
{ from: 'NEW', to: 'CONSULTANT_ASSIGNED', by: ASSIGN },
{ from: 'CONSULTANT_ASSIGNED', to: 'NEW', by: OPS },
{ from: 'CONSULTANT_ASSIGNED', to: 'RESUME_RECEIVED', by: ['CLIENT', 'PROVIDER', 'ADMIN', 'SYSTEM'] },
{ from: 'RESUME_RECEIVED', to: 'MEETING', by: PRO, action: 'startMeeting' },
{ from: 'MEETING', to: 'PLAN_SENT', by: PRO, action: 'finishMeeting', guards: ['checklistComplete'] },
],
events: [{ code: 'openMeeting', at: ['RESUME_RECEIVED'], by: PRO }],
};
/** Документы: Новый → Документы проверяются → Подача → Готово (+ «Нужны доп. документы») */
const DOCUMENTS: FlowDef = {
flow: 'DOCUMENTS',
steps: ['NEW', 'DOCS_REVIEW', 'SUBMITTED', 'DONE'],
initial: 'NEW',
assigned: null,
done: ['DONE'],
clientCancellable: ['NEW'],
checklistAt: [],
transitions: [
{ from: 'NEW', to: 'DOCS_REVIEW', by: PRO },
{ from: 'DOCS_REVIEW', to: 'NEEDS_MORE_DOCS', by: PRO },
{ from: 'NEEDS_MORE_DOCS', to: 'DOCS_REVIEW', by: ['CLIENT', 'PROVIDER', 'ADMIN', 'SYSTEM'] },
{ from: 'DOCS_REVIEW', to: 'SUBMITTED', by: PRO },
{ from: 'SUBMITTED', to: 'NEEDS_MORE_DOCS', by: PRO },
{ from: 'SUBMITTED', to: 'DONE', by: PRO },
],
events: [],
};
export const FLOWS: Record<ServiceFlow, FlowDef> = {
CLEANING,
REPAIR,
TRANSFER,
LANGUAGE,
CAREER,
DOCUMENTS,
};
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import type { ActorType, OrderStatus, ServiceFlow } from '../types.ts';
import { FLOWS } from './definitions.ts';
import { checkTransition, flowStatuses, kanbanColumn, providerActions } from './machine.ts';
import type { GuardContext } from './types.ts';
type Step = [OrderStatus, OrderStatus, ActorType, GuardContext?];
/** Счастливый путь каждой категории — как в таблице STS */
const HAPPY: Record<ServiceFlow, Step[]> = {
CLEANING: [
['NEW', 'ASSIGNED', 'SYSTEM'],
['ASSIGNED', 'EN_ROUTE', 'PROVIDER'],
['EN_ROUTE', 'CLEANING', 'PROVIDER'],
['CLEANING', 'DONE', 'PROVIDER', { checklistComplete: true }],
],
REPAIR: [
['NEW', 'ASSIGNED', 'SYSTEM'],
['ASSIGNED', 'ON_SITE', 'PROVIDER'],
['ON_SITE', 'ESTIMATE_APPROVED', 'CLIENT', { estimateAccepted: true }],
['ESTIMATE_APPROVED', 'REPAIRING', 'PROVIDER', { estimateAccepted: true }],
['REPAIRING', 'DONE', 'PROVIDER', { checklistComplete: true }],
],
TRANSFER: [
['NEW', 'ASSIGNED', 'SYSTEM'],
['ASSIGNED', 'DRIVER_WAITING', 'PROVIDER'],
['DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'PROVIDER'],
['RIDE_IN_PROGRESS', 'COMPLETED', 'PROVIDER', { checklistComplete: true }],
],
LANGUAGE: [
['ENROLLED', 'LESSON_SCHEDULED', 'PROVIDER', { lessonsTotal: 2, lessonsCompleted: 0 }],
['LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'PROVIDER'],
['LESSON_IN_PROGRESS', 'LESSON_DONE', 'PROVIDER', { checklistComplete: true }],
['LESSON_DONE', 'LESSON_SCHEDULED', 'PROVIDER', { lessonsTotal: 2, lessonsCompleted: 1 }],
['LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'PROVIDER'],
['LESSON_IN_PROGRESS', 'LESSON_DONE', 'PROVIDER', { checklistComplete: true }],
['LESSON_DONE', 'COMPLETED', 'SYSTEM', { lessonsTotal: 2, lessonsCompleted: 2 }],
],
CAREER: [
['NEW', 'CONSULTANT_ASSIGNED', 'SYSTEM'],
['CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'CLIENT'],
['RESUME_RECEIVED', 'MEETING', 'PROVIDER'],
['MEETING', 'PLAN_SENT', 'PROVIDER', { checklistComplete: true }],
],
DOCUMENTS: [
['NEW', 'DOCS_REVIEW', 'ADMIN'],
['DOCS_REVIEW', 'NEEDS_MORE_DOCS', 'ADMIN'],
['NEEDS_MORE_DOCS', 'DOCS_REVIEW', 'CLIENT'],
['DOCS_REVIEW', 'SUBMITTED', 'ADMIN'],
['SUBMITTED', 'DONE', 'ADMIN'],
],
};
/** Недопустимые переходы — «перепрыгнуть» шаг или вернуться из финала */
const INVALID: Record<ServiceFlow, [OrderStatus, OrderStatus][]> = {
CLEANING: [['NEW', 'CLEANING'], ['NEW', 'DONE'], ['ASSIGNED', 'DONE'], ['DONE', 'NEW'], ['EN_ROUTE', 'ASSIGNED']],
REPAIR: [['ASSIGNED', 'REPAIRING'], ['ON_SITE', 'REPAIRING'], ['NEW', 'DONE'], ['DONE', 'REPAIRING']],
TRANSFER: [['NEW', 'RIDE_IN_PROGRESS'], ['ASSIGNED', 'COMPLETED'], ['COMPLETED', 'ASSIGNED']],
LANGUAGE: [['ENROLLED', 'LESSON_DONE'], ['ENROLLED', 'COMPLETED'], ['LESSON_SCHEDULED', 'LESSON_DONE']],
CAREER: [['NEW', 'MEETING'], ['CONSULTANT_ASSIGNED', 'PLAN_SENT'], ['PLAN_SENT', 'NEW']],
DOCUMENTS: [['NEW', 'SUBMITTED'], ['NEW', 'DONE'], ['NEEDS_MORE_DOCS', 'DONE'], ['DONE', 'DOCS_REVIEW']],
};
describe.each(Object.keys(FLOWS) as ServiceFlow[])('state machine: %s', (flow) => {
it('проходит счастливый путь', () => {
let status = FLOWS[flow].initial;
for (const [from, to, actor, ctx] of HAPPY[flow]) {
expect(status).toBe(from);
const res = checkTransition(flow, from, to, actor, ctx);
expect(res, `${from} → ${to} by ${actor}`).toMatchObject({ ok: true });
status = to;
}
expect(FLOWS[flow].done).toContain(status);
});
it.each(INVALID[flow])('отклоняет %s → %s', (from, to) => {
for (const actor of ['CLIENT', 'PROVIDER', 'ADMIN', 'SYSTEM'] as const) {
const res = checkTransition(flow, from, to, actor, {
checklistComplete: true,
estimateAccepted: true,
lessonsTotal: 4,
lessonsCompleted: 4,
});
expect(res.ok).toBe(false);
}
});
it('отклоняет статусы чужой категории', () => {
const foreign = (['CLEANING', 'DRIVER_WAITING', 'LESSON_DONE', 'SUBMITTED'] as OrderStatus[]).find(
(s) => !flowStatuses(flow).includes(s),
)!;
expect(checkTransition(flow, FLOWS[flow].initial, foreign, 'ADMIN')).toEqual({ ok: false, code: 'UNKNOWN_STATUS' });
});
it('клиент не двигает рабочие шаги исполнителя', () => {
for (const t of FLOWS[flow].transitions.filter((x) => !x.by.includes('CLIENT'))) {
expect(checkTransition(flow, t.from, t.to, 'CLIENT', { checklistComplete: true }).ok).toBe(false);
}
});
it('отмена: клиент — только из ранних статусов, админ — из любого нефинального, из финала — никто', () => {
const def = FLOWS[flow];
for (const s of flowStatuses(flow)) {
const terminal = s === 'CANCELLED' || def.done.includes(s);
expect(checkTransition(flow, s, 'CANCELLED', 'ADMIN').ok).toBe(!terminal);
expect(checkTransition(flow, s, 'CANCELLED', 'CLIENT').ok).toBe(!terminal && def.clientCancellable.includes(s));
expect(checkTransition(flow, s, 'CANCELLED', 'PROVIDER').ok).toBe(false);
}
});
});
describe('guards', () => {
it('клининг нельзя закрыть без чек-листа (PRO-4)', () => {
expect(checkTransition('CLEANING', 'CLEANING', 'DONE', 'PROVIDER', { checklistComplete: false })).toEqual({
ok: false,
code: 'GUARD_FAILED',
guard: 'checklistComplete',
});
// и админ тоже не может обойти
expect(checkTransition('CLEANING', 'CLEANING', 'DONE', 'ADMIN').ok).toBe(false);
});
it('ремонт не начинается без согласованной сметы (STS-1)', () => {
expect(checkTransition('REPAIR', 'ON_SITE', 'ESTIMATE_APPROVED', 'CLIENT', { estimateAccepted: false }).ok).toBe(false);
expect(checkTransition('REPAIR', 'ESTIMATE_APPROVED', 'REPAIRING', 'PROVIDER', { estimateAccepted: false }).ok).toBe(false);
expect(checkTransition('REPAIR', 'ON_SITE', 'ESTIMATE_APPROVED', 'PROVIDER', { estimateAccepted: true })).toMatchObject({
ok: false,
code: 'ACTOR_NOT_ALLOWED',
});
});
it('языки: нельзя запланировать урок сверх пакета и закончить пакет раньше', () => {
expect(checkTransition('LANGUAGE', 'LESSON_DONE', 'LESSON_SCHEDULED', 'PROVIDER', { lessonsTotal: 4, lessonsCompleted: 4 }).ok).toBe(false);
expect(checkTransition('LANGUAGE', 'LESSON_DONE', 'COMPLETED', 'SYSTEM', { lessonsTotal: 4, lessonsCompleted: 3 }).ok).toBe(false);
});
});
describe('providerActions', () => {
it('клинер: Выехал → Я на месте + Начать уборку → Завершить', () => {
expect(providerActions('CLEANING', 'ASSIGNED').map((a) => a.code)).toEqual(['depart']);
expect(providerActions('CLEANING', 'EN_ROUTE').map((a) => a.code)).toEqual(['arrived', 'startCleaning']);
expect(providerActions('CLEANING', 'CLEANING')).toEqual([
{ kind: 'transition', code: 'finishCleaning', to: 'DONE', guards: ['checklistComplete'] },
]);
expect(providerActions('CLEANING', 'DONE')).toEqual([]);
});
it('водитель: Я на месте подачи → Пассажир в машине → Завершить поездку', () => {
expect(providerActions('TRANSFER', 'ASSIGNED').map((a) => a.code)).toEqual(['atPickup']);
expect(providerActions('TRANSFER', 'DRIVER_WAITING').map((a) => a.code)).toEqual(['passengerIn']);
expect(providerActions('TRANSFER', 'RIDE_IN_PROGRESS').map((a) => a.code)).toEqual(['finishRide']);
});
});
describe('kanbanColumn', () => {
it('раскладывает по колонкам ADM-1', () => {
expect(kanbanColumn('CLEANING', 'NEW')).toBe('new');
expect(kanbanColumn('CLEANING', 'NEW', { hasPendingOffer: true })).toBe('assigned');
expect(kanbanColumn('CLEANING', 'NEW', { hasCompany: true })).toBe('assigned');
expect(kanbanColumn('CLEANING', 'ASSIGNED')).toBe('assigned');
expect(kanbanColumn('CLEANING', 'EN_ROUTE')).toBe('in_progress');
expect(kanbanColumn('CLEANING', 'DONE')).toBe('done');
expect(kanbanColumn('TRANSFER', 'COMPLETED')).toBe('done');
expect(kanbanColumn('CLEANING', 'CANCELLED')).toBe('cancelled');
});
});
+3
View File
@@ -0,0 +1,3 @@
export * from './types.ts';
export * from './definitions.ts';
export * from './machine.ts';
+122
View File
@@ -0,0 +1,122 @@
import type { ActorType, OrderStatus, ServiceFlow } from '../types.ts';
import { FLOWS } from './definitions.ts';
import type { FlowDef, FlowEvent, Guard, GuardContext, Transition, TransitionCheck } from './types.ts';
export function getFlow(flow: ServiceFlow): FlowDef {
return FLOWS[flow];
}
/** Все статусы, которые встречаются в сценарии (по порядку появления) */
export function flowStatuses(flow: ServiceFlow): OrderStatus[] {
const def = FLOWS[flow];
const seen = new Set<OrderStatus>([def.initial]);
for (const t of def.transitions) {
seen.add(t.from);
seen.add(t.to);
}
seen.add('CANCELLED');
return [...seen];
}
export function isTerminal(flow: ServiceFlow, status: OrderStatus): boolean {
return status === 'CANCELLED' || FLOWS[flow].done.includes(status);
}
function checkGuard(guard: Guard, ctx: GuardContext): boolean {
switch (guard) {
case 'checklistComplete':
return ctx.checklistComplete === true;
case 'estimateAccepted':
return ctx.estimateAccepted === true;
case 'lessonsRemaining':
return (ctx.lessonsTotal ?? 0) > (ctx.lessonsCompleted ?? 0);
case 'allLessonsDone':
return (ctx.lessonsTotal ?? 0) > 0 && (ctx.lessonsCompleted ?? 0) >= (ctx.lessonsTotal ?? 0);
}
}
/**
* Можно ли перевести заказ из `from` в `to`.
* Отмена разрешена клиенту из clientCancellable, админу и системе — из любого нефинального статуса.
*/
export function checkTransition(
flow: ServiceFlow,
from: OrderStatus,
to: OrderStatus,
actor: ActorType,
ctx: GuardContext = {},
): TransitionCheck {
const def = FLOWS[flow];
const statuses = flowStatuses(flow);
if (!statuses.includes(from) || !statuses.includes(to)) return { ok: false, code: 'UNKNOWN_STATUS' };
if (to === 'CANCELLED') {
if (isTerminal(flow, from)) return { ok: false, code: 'TRANSITION_NOT_ALLOWED' };
if (actor === 'ADMIN' || actor === 'SYSTEM') return { ok: true, transition: null };
if (actor === 'CLIENT' && def.clientCancellable.includes(from)) return { ok: true, transition: null };
return { ok: false, code: 'ACTOR_NOT_ALLOWED' };
}
const candidates = def.transitions.filter((t) => t.from === from && t.to === to);
if (candidates.length === 0) return { ok: false, code: 'TRANSITION_NOT_ALLOWED' };
const transition = candidates.find((t) => t.by.includes(actor));
if (!transition) return { ok: false, code: 'ACTOR_NOT_ALLOWED' };
for (const guard of transition.guards ?? []) {
if (!checkGuard(guard, ctx)) return { ok: false, code: 'GUARD_FAILED', guard };
}
return { ok: true, transition };
}
/** Переход по коду кнопки исполнителя из текущего статуса */
export function findActionTransition(flow: ServiceFlow, from: OrderStatus, action: string): Transition | undefined {
return FLOWS[flow].transitions.find((t) => t.from === from && t.action === action);
}
export function findEvent(flow: ServiceFlow, status: OrderStatus, code: string): FlowEvent | undefined {
return FLOWS[flow].events.find((e) => e.code === code && e.at.includes(status));
}
export interface ProAction {
kind: 'transition' | 'event';
code: string;
to?: OrderStatus;
guards: readonly Guard[];
}
/** Кнопки, доступные исполнителю в текущем статусе (PRO-3) */
export function providerActions(flow: ServiceFlow, status: OrderStatus): ProAction[] {
const def = FLOWS[flow];
const events: ProAction[] = def.events
.filter((e) => e.at.includes(status) && e.by.includes('PROVIDER'))
.map((e) => ({ kind: 'event', code: e.code, guards: [] }));
const transitions: ProAction[] = def.transitions
.filter((t) => t.from === status && t.action && t.by.includes('PROVIDER'))
.map((t) => ({ kind: 'transition', code: t.action!, to: t.to, guards: t.guards ?? [] }));
return [...events, ...transitions];
}
export type KanbanColumn = 'new' | 'assigned' | 'in_progress' | 'done' | 'cancelled';
/**
* Колонка канбана (ADM-1). Заказ, предложенный исполнителю или переданный компании,
* но ещё не принятый, показывается в «Назначены» с пометкой.
*/
export function kanbanColumn(
flow: ServiceFlow,
status: OrderStatus,
opts: { hasProvider?: boolean; hasPendingOffer?: boolean; hasCompany?: boolean } = {},
): KanbanColumn {
const def = FLOWS[flow];
if (status === 'CANCELLED') return 'cancelled';
if (def.done.includes(status)) return 'done';
if (status === def.initial) {
return opts.hasProvider || opts.hasPendingOffer || opts.hasCompany ? 'assigned' : 'new';
}
if (status === def.assigned) return 'assigned';
return 'in_progress';
}
/** Работа по заказу уже началась (исполнитель выехал и т.п.) */
export function isInProgress(flow: ServiceFlow, status: OrderStatus): boolean {
return kanbanColumn(flow, status, { hasProvider: true }) === 'in_progress';
}
+63
View File
@@ -0,0 +1,63 @@
import type { ActorType, OrderStatus, ServiceFlow } from '../types.ts';
/** Условия, которые проверяются перед переходом */
export type Guard =
/** все пункты чек-листа отмечены, фото приложены там, где нужно */
| 'checklistComplete'
/** клиент принял смету (ремонт) */
| 'estimateAccepted'
/** в пакете остались уроки */
| 'lessonsRemaining'
/** все уроки пакета пройдены */
| 'allLessonsDone';
export interface Transition {
from: OrderStatus;
to: OrderStatus;
by: readonly ActorType[];
/** код кнопки исполнителя; i18n: pro.action.<action> */
action?: string;
guards?: readonly Guard[];
}
/** Событие без смены статуса — например «Я на месте» */
export interface FlowEvent {
code: string;
at: readonly OrderStatus[];
by: readonly ActorType[];
}
export interface FlowDef {
flow: ServiceFlow;
/** основной путь — для шкалы прогресса у клиента */
steps: readonly OrderStatus[];
/** статус при создании заказа */
initial: OrderStatus;
/** статус, в который заказ переходит, когда исполнитель принял предложение (null — статус не меняется) */
assigned: OrderStatus | null;
/** успешные финальные статусы */
done: readonly OrderStatus[];
/** из каких статусов клиент может отменить сам */
clientCancellable: readonly OrderStatus[];
/** статусы, где у исполнителя идёт работа и показывается чек-лист */
checklistAt: readonly OrderStatus[];
transitions: readonly Transition[];
events: readonly FlowEvent[];
}
export interface GuardContext {
checklistComplete?: boolean;
estimateAccepted?: boolean;
lessonsTotal?: number;
lessonsCompleted?: number;
}
export type TransitionErrorCode =
| 'UNKNOWN_STATUS'
| 'TRANSITION_NOT_ALLOWED'
| 'ACTOR_NOT_ALLOWED'
| 'GUARD_FAILED';
export type TransitionCheck =
| { ok: true; transition: Transition | null }
| { ok: false; code: TransitionErrorCode; guard?: Guard };
+14
View File
@@ -0,0 +1,14 @@
export * from './types.ts';
export * from './flows/index.ts';
export * from './params.ts';
export * from './pricing.ts';
export * from './time.ts';
export * from './slots.ts';
export * from './assignment.ts';
export * from './cancellation.ts';
export * from './deeplink.ts';
export * from './membership.ts';
export * from './promo.ts';
export * from './settings.ts';
export * from './payments.ts';
export * from './catalog.ts';
+59
View File
@@ -0,0 +1,59 @@
import { addMonths, DAY } from './time.ts';
export interface SubscriptionLike {
startsAt: Date;
endsAt: Date;
status: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
}
export type MembershipLevel = 'BAREV' | 'BAREV_PLUS';
export function activeSubscription<T extends SubscriptionLike>(subs: T[], now: Date): T | null {
return subs.find((s) => s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now) ?? null;
}
export function membershipLevel(subs: SubscriptionLike[], now: Date): MembershipLevel {
return activeSubscription(subs, now) ? 'BAREV_PLUS' : 'BAREV';
}
/** Скидка Barev+ по направлению применяется только при активной подписке (SUB-2) */
export function discountFor(level: MembershipLevel, categoryDiscountPct: number): number {
return level === 'BAREV_PLUS' ? categoryDiscountPct : 0;
}
export type GiftDecision =
| { ok: true; startsAt: Date; endsAt: Date }
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' };
/**
* SUB-3 / ONB-4: подарочный месяц по коду агентства, один раз на клиента.
* Если у клиента уже есть активный Barev+, подарок продлевает его с даты окончания.
*/
export function decideGift(input: {
now: Date;
giftUsedAt: Date | null;
agencyActive: boolean;
giftDays: number;
currentEndsAt: Date | null;
}): GiftDecision {
if (input.giftUsedAt) return { ok: false, reason: 'ALREADY_USED' };
if (!input.agencyActive) return { ok: false, reason: 'AGENCY_INACTIVE' };
const startsAt = input.currentEndsAt && input.currentEndsAt > input.now ? input.currentEndsAt : input.now;
return { ok: true, startsAt, endsAt: new Date(startsAt.getTime() + input.giftDays * DAY) };
}
/** Клиент «принадлежит» агентству N месяцев с первого /start (решение владельца) */
export function isAgencyAttributionActive(attachedAt: Date | null, now: Date, months: number): boolean {
if (!attachedAt) return false;
return now < addMonths(attachedAt, months);
}
/** ADM-8 / PAY-3: вознаграждение агентства с оплаченного заказа */
export function agencyCommission(totalPrice: number, pct: number): number {
return Math.floor((totalPrice * pct) / 100);
}
/** PRO-8: выплата исполнителю или компании по заказу */
export function payoutFor(totalPrice: number, rate: { type: 'FIXED' | 'PERCENT'; value: number }): number {
return rate.type === 'FIXED' ? rate.value : Math.floor((totalPrice * rate.value) / 100);
}
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod';
/**
* Описание параметров формы заказа (Service.paramsSchema).
* Подписи: i18n `params.<serviceCode>.<key>.label`, варианты — `params.<serviceCode>.<key>.<option>`.
*/
export const paramFieldSchema = z.discriminatedUnion('type', [
z.object({
key: z.string(),
type: z.literal('select'),
options: z.array(z.string()).min(1),
default: z.string().optional(),
required: z.boolean().default(true),
}),
z.object({
key: z.string(),
type: z.literal('int'),
min: z.number().int(),
max: z.number().int(),
default: z.number().int().optional(),
required: z.boolean().default(true),
}),
z.object({
key: z.string(),
type: z.literal('text'),
maxLength: z.number().int().default(500),
required: z.boolean().default(false),
}),
z.object({
key: z.string(),
type: z.literal('bool'),
default: z.boolean().optional(),
required: z.boolean().default(false),
}),
]);
export type ParamField = z.infer<typeof paramFieldSchema>;
export const paramsSchemaSchema = z.object({ fields: z.array(paramFieldSchema) });
export type ParamsSchema = z.infer<typeof paramsSchemaSchema>;
export type ParamValues = Record<string, string | number | boolean>;
export function defaultParams(schema: ParamsSchema): ParamValues {
const out: ParamValues = {};
for (const f of schema.fields) {
if (f.type === 'select') out[f.key] = f.default ?? f.options[0]!;
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
else if (f.type === 'bool') out[f.key] = f.default ?? false;
}
return out;
}
export type ParamsError = { key: string; code: 'required' | 'invalid' };
/** Проверяет и нормализует значения; лишние ключи отбрасываются */
export function validateParams(
schema: ParamsSchema,
input: Record<string, unknown>,
): { ok: true; values: ParamValues } | { ok: false; errors: ParamsError[] } {
const values: ParamValues = {};
const errors: ParamsError[] = [];
for (const f of schema.fields) {
const raw = input[f.key];
const missing = raw === undefined || raw === null || raw === '';
if (missing) {
if (f.required) errors.push({ key: f.key, code: 'required' });
continue;
}
switch (f.type) {
case 'select':
if (typeof raw === 'string' && f.options.includes(raw)) values[f.key] = raw;
else errors.push({ key: f.key, code: 'invalid' });
break;
case 'int': {
const n = typeof raw === 'number' ? raw : Number(raw);
if (Number.isInteger(n) && n >= f.min && n <= f.max) values[f.key] = n;
else errors.push({ key: f.key, code: 'invalid' });
break;
}
case 'text':
if (typeof raw === 'string' && raw.length <= f.maxLength) values[f.key] = raw.trim();
else errors.push({ key: f.key, code: 'invalid' });
break;
case 'bool':
if (typeof raw === 'boolean') values[f.key] = raw;
else errors.push({ key: f.key, code: 'invalid' });
break;
}
}
return errors.length ? { ok: false, errors } : { ok: true, values };
}
+36
View File
@@ -0,0 +1,36 @@
/**
* PAY-2: абстракция платёжного провайдера. В Этапах 1–2 работает только ManualPaymentProvider —
* оператор или исполнитель отмечает «оплачено» вручную. Реальный провайдер (Армения) появится в Этапе 3.
*/
export interface PaymentIntent {
orderId: string;
amount: number;
currency: 'AMD';
description: string;
}
export type PaymentResult =
| { status: 'PAID'; provider: string; externalId?: string }
| { status: 'PENDING'; provider: string; redirectUrl?: string; externalId?: string };
export interface PaymentProvider {
readonly code: string;
/** может ли клиент оплатить сам (онлайн) */
readonly online: boolean;
createPayment(intent: PaymentIntent): Promise<PaymentResult>;
/** ручная отметка; для онлайн-провайдеров — подтверждение по webhook */
confirmPayment(intent: PaymentIntent, meta: { markedBy: string }): Promise<PaymentResult>;
}
export class ManualPaymentProvider implements PaymentProvider {
readonly code = 'manual';
readonly online = false;
async createPayment(_intent: PaymentIntent): Promise<PaymentResult> {
return { status: 'PENDING', provider: this.code };
}
async confirmPayment(_intent: PaymentIntent, _meta: { markedBy: string }): Promise<PaymentResult> {
return { status: 'PAID', provider: this.code };
}
}
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
import type { ParamValues } from './params.ts';
/**
* Правила цены услуги (Service.pricing). Все суммы — целые драмы.
*
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату сверх первой.
* Длительность считается так же.
* - `fixed`: фиксированная цена (заглушка для услуг Этапа 2).
*/
const roomsTier = z.object({
base: z.number().int().nonnegative(),
perRoom: z.number().int().nonnegative(),
baseMinutes: z.number().int().positive(),
perRoomMinutes: z.number().int().nonnegative(),
});
export const pricingSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('rooms'),
/** параметр-вариант, например cleaningType */
variantParam: z.string(),
roomsParam: z.string(),
tiers: z.record(z.string(), roomsTier),
}),
z.object({
kind: z.literal('fixed'),
price: z.number().int().nonnegative(),
minutes: z.number().int().positive(),
}),
]);
export type Pricing = z.infer<typeof pricingSchema>;
export interface Quote {
basePrice: number;
discountPct: number;
discountAmount: number;
totalPrice: number;
durationMinutes: number;
}
export class PricingError extends Error {
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT') {
super(code);
}
}
function baseOf(pricing: Pricing, params: ParamValues): { price: number; minutes: number } {
if (pricing.kind === 'fixed') return { price: pricing.price, minutes: pricing.minutes };
const tier = pricing.tiers[String(params[pricing.variantParam])];
if (!tier) throw new PricingError('UNKNOWN_VARIANT');
const rooms = Number(params[pricing.roomsParam]);
if (!Number.isInteger(rooms) || rooms < 1) throw new PricingError('BAD_ROOMS');
const extra = rooms - 1;
return {
price: tier.base + tier.perRoom * extra,
minutes: tier.baseMinutes + tier.perRoomMinutes * extra,
};
}
/**
* ORD-4: базовая цена по параметрам минус скидка Barev+ по направлению.
* Сумма скидки округляется вниз до целого драма.
*/
export function quote(pricing: Pricing, params: ParamValues, discountPct = 0): Quote {
if (!Number.isInteger(discountPct) || discountPct < 0 || discountPct > 100) throw new PricingError('BAD_DISCOUNT');
const { price, minutes } = baseOf(pricing, params);
const discountAmount = Math.floor((price * discountPct) / 100);
return {
basePrice: price,
discountPct,
discountAmount,
totalPrice: price - discountAmount,
durationMinutes: minutes,
};
}
/** Минимальная цена — для «от N ֏» на лендинге и в каталоге */
export function fromPrice(pricing: Pricing): number {
if (pricing.kind === 'fixed') return pricing.price;
return Math.min(...Object.values(pricing.tiers).map((t) => t.base));
}
export function formatAmd(amount: number, locale = 'ru'): string {
const n = new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale, { maximumFractionDigits: 0 }).format(amount);
return `${n} ֏`;
}
+26
View File
@@ -0,0 +1,26 @@
export interface PromoLike {
kind: 'AGENCY_GIFT' | 'DISCOUNT';
active: boolean;
maxUses: number | null;
usedCount: number;
validFrom: Date | null;
validUntil: Date | null;
}
export type PromoCheck =
| { ok: true }
| { ok: false; reason: 'INACTIVE' | 'NOT_STARTED' | 'EXPIRED' | 'EXHAUSTED' | 'ALREADY_USED_BY_CLIENT' };
export function checkPromo(promo: PromoLike, now: Date, usedByClient: boolean): PromoCheck {
if (!promo.active) return { ok: false, reason: 'INACTIVE' };
if (promo.validFrom && now < promo.validFrom) return { ok: false, reason: 'NOT_STARTED' };
if (promo.validUntil && now > promo.validUntil) return { ok: false, reason: 'EXPIRED' };
if (promo.maxUses !== null && promo.usedCount >= promo.maxUses) return { ok: false, reason: 'EXHAUSTED' };
if (usedByClient) return { ok: false, reason: 'ALREADY_USED_BY_CLIENT' };
return { ok: true };
}
/** Промокод-скидка не суммируется с Barev+: берётся большая из двух */
export function combineDiscounts(barevPlusPct: number, promoPct: number | null): number {
return Math.max(barevPlusPct, promoPct ?? 0);
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
/**
* Настройки, которые владелец меняет в админке (таблица Setting).
* Здесь — схема и значения по умолчанию; в коде порогов нет.
*/
export const settingsSchema = z.object({
/** ORD-6: бесплатная отмена/перенос не позднее чем за N часов */
'cancel.freeHours': z.number().int().min(0).max(168).default(24),
/** ADM-8: сколько месяцев клиент закреплён за агентством */
'agency.attributionMonths': z.number().int().min(1).max(60).default(12),
/** % агентства по умолчанию для новых агентств */
'agency.defaultCommissionPct': z.number().int().min(0).max(100).default(10),
/** SUB-3: длительность подарочного Barev+ */
'barevPlus.giftDays': z.number().int().min(1).max(365).default(30),
/** цена подписки в месяц, ֏ (заглушка до решения владельца) */
'barevPlus.monthlyPrice': z.number().int().min(0).default(4900),
/** PRO-5: сколько минут исполнитель может думать над предложением */
'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
'chat.closeAfterHours': z.number().int().min(0).default(24),
/** раздел 6: автоудаление сканов через N дней после завершения */
'files.retentionDays': z.number().int().min(1).default(90),
/** текущая версия политики ПДн — сохраняется вместе с согласием */
'consent.version': z.string().default('2026-10-draft'),
});
export type Settings = z.infer<typeof settingsSchema>;
export type SettingKey = keyof Settings;
export const SETTING_KEYS = Object.keys(settingsSchema.shape) as SettingKey[];
export const DEFAULT_SETTINGS: Settings = settingsSchema.parse({});
/** Собирает настройки из строк БД; битые значения заменяются дефолтом */
export function resolveSettings(rows: { key: string; value: unknown }[]): Settings {
const out: Record<string, unknown> = { ...DEFAULT_SETTINGS };
for (const r of rows) {
const field = settingsSchema.shape[r.key as SettingKey];
if (!field) continue;
const parsed = field.safeParse(r.value);
if (parsed.success) out[r.key] = parsed.data;
}
return out as Settings;
}
+74
View File
@@ -0,0 +1,74 @@
import { z } from 'zod';
import { fromYerevan, HOUR, parseDateKey, toYerevan } from './time.ts';
/** Окно записи услуги (Service.slotConfig) */
export const slotConfigSchema = z.object({
/** начало и конец окна, минуты от полуночи */
dayStartMinute: z.number().int().min(0).max(1440),
dayEndMinute: z.number().int().min(0).max(1440),
stepMinutes: z.number().int().positive(),
/** минимальный запас до начала, часы */
leadHours: z.number().nonnegative(),
/** на сколько дней вперёд открыта запись */
horizonDays: z.number().int().positive(),
});
export type SlotConfig = z.infer<typeof slotConfigSchema>;
export interface Interval {
start: Date;
end: Date;
}
export interface ProviderAvailability {
id: string;
/** недельный график: weekday 0 = Пн */
schedule: { weekday: number; startMinute: number; endMinute: number }[];
/** занятые интервалы (принятые заказы) */
busy: Interval[];
}
export interface Slot {
start: Date;
end: Date;
/** сколько исполнителей свободно с учётом ещё не назначенных заказов */
capacity: number;
}
const overlaps = (a: Interval, b: Interval) => a.start < b.end && b.start < a.end;
export function isProviderFree(p: ProviderAvailability, slot: Interval): boolean {
const s = toYerevan(slot.start);
const startMin = s.hour * 60 + s.minute;
const endMin = startMin + (slot.end.getTime() - slot.start.getTime()) / 60_000;
const inSchedule = p.schedule.some((w) => w.weekday === s.weekday && w.startMinute <= startMin && w.endMinute >= endMin);
return inSchedule && !p.busy.some((b) => overlaps(b, slot));
}
/**
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.
*/
export function daySlots(input: {
dateKey: string;
config: SlotConfig;
durationMinutes: number;
now: Date;
providers: ProviderAvailability[];
unassigned: Interval[];
}): Slot[] {
const { year, month, day } = parseDateKey(input.dateKey);
const { config } = input;
const earliest = input.now.getTime() + config.leadHours * HOUR;
const horizonEnd = input.now.getTime() + (config.horizonDays + 1) * 24 * HOUR;
const slots: Slot[] = [];
for (let m = config.dayStartMinute; m + input.durationMinutes <= config.dayEndMinute; m += config.stepMinutes) {
const start = fromYerevan(year, month, day, m);
if (start.getTime() < earliest || start.getTime() > horizonEnd) continue;
const slot = { start, end: new Date(start.getTime() + input.durationMinutes * 60_000) };
const free = input.providers.filter((p) => isProviderFree(p, slot)).length;
const taken = input.unassigned.filter((u) => overlaps(u, slot)).length;
const capacity = free - taken;
if (capacity > 0) slots.push({ ...slot, capacity });
}
return slots;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Время в Asia/Yerevan. В Армении нет перехода на летнее время с 2012 года,
* смещение постоянно UTC+4 — это позволяет считать слоты без tz-библиотек.
*/
export const YEREVAN_TZ = 'Asia/Yerevan';
export const YEREVAN_OFFSET_MINUTES = 4 * 60;
const MINUTE = 60_000;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export interface LocalParts {
year: number;
month: number; // 1–12
day: number;
hour: number;
minute: number;
/** 0 = понедельник … 6 = воскресенье */
weekday: number;
}
export function toYerevan(date: Date): LocalParts {
const local = new Date(date.getTime() + YEREVAN_OFFSET_MINUTES * MINUTE);
return {
year: local.getUTCFullYear(),
month: local.getUTCMonth() + 1,
day: local.getUTCDate(),
hour: local.getUTCHours(),
minute: local.getUTCMinutes(),
weekday: (local.getUTCDay() + 6) % 7,
};
}
/** Момент времени по местной дате и минутам от полуночи */
export function fromYerevan(year: number, month: number, day: number, minuteOfDay = 0): Date {
return new Date(Date.UTC(year, month - 1, day) + (minuteOfDay - YEREVAN_OFFSET_MINUTES) * MINUTE);
}
/** 'YYYY-MM-DD' по Еревану */
export function yerevanDateKey(date: Date): string {
const p = toYerevan(date);
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
}
export function parseDateKey(key: string): { year: number; month: number; day: number } {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
if (!m) throw new Error(`Bad date key: ${key}`);
return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
}
export function startOfYerevanDay(date: Date): Date {
const p = toYerevan(date);
return fromYerevan(p.year, p.month, p.day);
}
/** 'YYYY-MM' по Еревану — период для актов и выплат */
export function yerevanMonthKey(date: Date): string {
return yerevanDateKey(date).slice(0, 7);
}
export function addMonths(date: Date, months: number): Date {
const d = new Date(date.getTime());
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
+28
View File
@@ -0,0 +1,28 @@
// Типы домена. Повторяют enum'ы Prisma, но не зависят от него —
// core используется и во фронтендах.
export const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'ru';
export const isLocale = (v: unknown): v is Locale => typeof v === 'string' && (LOCALES as readonly string[]).includes(v);
export const CATEGORY_CODES = ['HOME', 'DOCS', 'LEARNING', 'PERKS'] as const;
export type CategoryCode = (typeof CATEGORY_CODES)[number];
export const SERVICE_FLOWS = ['CLEANING', 'REPAIR', 'TRANSFER', 'LANGUAGE', 'CAREER', 'DOCUMENTS'] as const;
export type ServiceFlow = (typeof SERVICE_FLOWS)[number];
export const PROVIDER_ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
export type ProviderRole = (typeof PROVIDER_ROLES)[number];
export const ORDER_STATUSES = [
'NEW', 'ASSIGNED', 'EN_ROUTE', 'CLEANING', 'ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING',
'DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'ENROLLED', 'LESSON_SCHEDULED', 'LESSON_IN_PROGRESS',
'LESSON_DONE', 'CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'MEETING', 'PLAN_SENT', 'DOCS_REVIEW',
'NEEDS_MORE_DOCS', 'SUBMITTED', 'DONE', 'COMPLETED', 'CANCELLED',
] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];
export type ActorType = 'CLIENT' | 'PROVIDER' | 'DISPATCHER' | 'ADMIN' | 'SYSTEM';
export type RateType = 'FIXED' | 'PERCENT';
export type PaymentStatus = 'UNPAID' | 'PAID' | 'REFUNDED';
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "include": ["src"] }
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@barev/db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"generate": "prisma generate",
"migrate": "prisma migrate dev",
"deploy": "prisma migrate deploy",
"reset": "prisma migrate reset --force",
"seed": "tsx src/seed/index.ts",
"studio": "prisma studio",
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/adapter-pg": "7.10.1",
"@prisma/client": "7.10.1",
"pg": "^8.16.0"
},
"devDependencies": {
"@barev/core": "workspace:*",
"@node-rs/argon2": "^2.0.2",
"dotenv": "^17.0.0",
"prisma": "7.10.1",
"tsx": "^4.20.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
import path from 'node:path';
import { config } from 'dotenv';
import { defineConfig, env } from 'prisma/config';
config({ path: path.resolve(import.meta.dirname, '../../.env'), quiet: true });
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations', seed: 'tsx src/seed/index.ts' },
datasource: { url: env('DATABASE_URL') },
});
@@ -0,0 +1,824 @@
-- CreateEnum
CREATE TYPE "Locale" AS ENUM ('ru', 'en', 'zh', 'hy', 'es');
-- CreateEnum
CREATE TYPE "CategoryCode" AS ENUM ('HOME', 'DOCS', 'LEARNING', 'PERKS');
-- CreateEnum
CREATE TYPE "ServiceFlow" AS ENUM ('CLEANING', 'REPAIR', 'TRANSFER', 'LANGUAGE', 'CAREER', 'DOCUMENTS');
-- CreateEnum
CREATE TYPE "ProviderRole" AS ENUM ('CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('NEW', 'ASSIGNED', 'EN_ROUTE', 'CLEANING', 'ON_SITE', 'ESTIMATE_APPROVED', 'REPAIRING', 'DRIVER_WAITING', 'RIDE_IN_PROGRESS', 'ENROLLED', 'LESSON_SCHEDULED', 'LESSON_IN_PROGRESS', 'LESSON_DONE', 'CONSULTANT_ASSIGNED', 'RESUME_RECEIVED', 'MEETING', 'PLAN_SENT', 'DOCS_REVIEW', 'NEEDS_MORE_DOCS', 'SUBMITTED', 'DONE', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('CLIENT', 'PROVIDER', 'DISPATCHER', 'ADMIN', 'SYSTEM');
-- CreateEnum
CREATE TYPE "OfferStatus" AS ENUM ('OFFERED', 'ACCEPTED', 'DECLINED', 'EXPIRED', 'REVOKED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PAID', 'REFUNDED');
-- CreateEnum
CREATE TYPE "RateType" AS ENUM ('FIXED', 'PERCENT');
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('OWNER', 'OPERATOR');
-- CreateEnum
CREATE TYPE "SubscriptionPlan" AS ENUM ('BAREV_PLUS');
-- CreateEnum
CREATE TYPE "SubscriptionSource" AS ENUM ('AGENCY_GIFT', 'MANUAL', 'PAID');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'EXPIRED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PromoKind" AS ENUM ('AGENCY_GIFT', 'DISCOUNT');
-- CreateEnum
CREATE TYPE "FileKind" AS ENUM ('PHOTO_REPORT', 'CLIENT_PHOTO', 'DOCUMENT_SCAN', 'RESUME', 'OTHER');
-- CreateEnum
CREATE TYPE "EstimateStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('OPEN', 'DONE');
-- CreateEnum
CREATE TYPE "PayoutStatus" AS ENUM ('DRAFT', 'APPROVED', 'PAID');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"telegramId" BIGINT NOT NULL,
"username" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"phone" TEXT,
"locale" "Locale" NOT NULL DEFAULT 'ru',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Client" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"consentAt" TIMESTAMP(3),
"consentVersion" TEXT,
"agencyId" TEXT,
"agencyAttachedAt" TIMESTAMP(3),
"source" TEXT NOT NULL DEFAULT 'organic',
"utm" JSONB,
"giftUsedAt" TIMESTAMP(3),
"cardCode" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProviderCompany" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"roles" "ProviderRole"[],
"rateType" "RateType" NOT NULL DEFAULT 'PERCENT',
"rateValue" INTEGER NOT NULL DEFAULT 70,
"contactPhone" TEXT,
"inviteCode" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderCompany_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CompanyDispatcher" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CompanyDispatcher_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Provider" (
"id" TEXT NOT NULL,
"userId" TEXT,
"name" TEXT NOT NULL,
"phone" TEXT,
"role" "ProviderRole" NOT NULL,
"companyId" TEXT,
"languages" "Locale"[],
"rateType" "RateType" NOT NULL DEFAULT 'PERCENT',
"rateValue" INTEGER NOT NULL DEFAULT 70,
"ratingSum" INTEGER NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"onShift" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"inviteCode" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Provider_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "District" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "District_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProviderDistrict" (
"providerId" TEXT NOT NULL,
"districtId" TEXT NOT NULL,
CONSTRAINT "ProviderDistrict_pkey" PRIMARY KEY ("providerId","districtId")
);
-- CreateTable
CREATE TABLE "ProviderSchedule" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"weekday" INTEGER NOT NULL,
"startMinute" INTEGER NOT NULL,
"endMinute" INTEGER NOT NULL,
CONSTRAINT "ProviderSchedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"code" "CategoryCode" NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"barevPlusDiscountPct" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Service" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"flow" "ServiceFlow" NOT NULL,
"providerRole" "ProviderRole" NOT NULL,
"pricing" JSONB NOT NULL,
"paramsSchema" JSONB NOT NULL,
"slotConfig" JSONB NOT NULL,
"lateCancelFeePct" INTEGER NOT NULL DEFAULT 30,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Service_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChecklistItem" (
"id" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"requiresPhoto" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ChecklistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"params" JSONB NOT NULL,
"scheduledAt" TIMESTAMP(3) NOT NULL,
"durationMinutes" INTEGER NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"addressText" TEXT,
"districtId" TEXT,
"lat" DOUBLE PRECISION,
"lng" DOUBLE PRECISION,
"note" TEXT,
"locale" "Locale" NOT NULL,
"basePrice" INTEGER NOT NULL,
"discountPct" INTEGER NOT NULL DEFAULT 0,
"discountAmount" INTEGER NOT NULL DEFAULT 0,
"totalPrice" INTEGER NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'NEW',
"providerId" TEXT,
"companyId" TEXT,
"paymentMethodId" TEXT,
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
"paidAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"cancelFee" INTEGER NOT NULL DEFAULT 0,
"cancelReason" TEXT,
"completedAt" TIMESTAMP(3),
"repeatOfId" TEXT,
"reminded24hAt" TIMESTAMP(3),
"reminded1hAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderStatusHistory" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"fromStatus" "OrderStatus",
"toStatus" "OrderStatus" NOT NULL,
"event" TEXT,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderOffer" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"providerId" TEXT,
"companyId" TEXT,
"status" "OfferStatus" NOT NULL DEFAULT 'OFFERED',
"expiresAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderOffer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderChecklistItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"requiresPhoto" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"checkedAt" TIMESTAMP(3),
"fileId" TEXT,
CONSTRAINT "OrderChecklistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderFile" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"kind" "FileKind" NOT NULL,
"storageKey" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"uploadedBy" "ActorType" NOT NULL,
"uploadedById" TEXT,
"deleteAfter" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileAccessLog" (
"id" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileAccessLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Estimate" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"total" INTEGER NOT NULL,
"status" "EstimateStatus" NOT NULL DEFAULT 'PENDING',
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Estimate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"fromType" "ActorType" NOT NULL,
"fromUserId" TEXT,
"text" TEXT,
"fileId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rating" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"providerId" TEXT,
"score" INTEGER NOT NULL,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LessonPackage" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"total" INTEGER NOT NULL,
"completed" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "LessonPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"status" "PaymentStatus" NOT NULL,
"externalId" TEXT,
"markedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OperatorTask" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"orderId" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'OPEN',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"doneAt" TIMESTAMP(3),
CONSTRAINT "OperatorTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agency" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"commissionPct" INTEGER NOT NULL DEFAULT 10,
"contact" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Agency_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgencyCommission" (
"id" TEXT NOT NULL,
"agencyId" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"pct" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"period" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgencyCommission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromoCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"kind" "PromoKind" NOT NULL,
"agencyId" TEXT,
"discountPct" INTEGER,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromoCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromoCodeUse" (
"id" TEXT NOT NULL,
"promoId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"orderId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromoCodeUse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"plan" "SubscriptionPlan" NOT NULL DEFAULT 'BAREV_PLUS',
"source" "SubscriptionSource" NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"remindedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Partner" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"name" TEXT NOT NULL,
"offer" JSONB NOT NULL,
"terms" JSONB,
"address" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CardUsage" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"partnerId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CardUsage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payout" (
"id" TEXT NOT NULL,
"providerId" TEXT,
"companyId" TEXT,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"amount" INTEGER NOT NULL,
"status" "PayoutStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'OPERATOR',
"totpSecret" TEXT,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminSession" (
"id" TEXT NOT NULL,
"adminUserId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"adminUserId" TEXT,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT,
"data" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Setting" (
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "StartToken" (
"id" TEXT NOT NULL,
"locale" "Locale",
"agency" TEXT,
"utm" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StartToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId");
-- CreateIndex
CREATE UNIQUE INDEX "Client_userId_key" ON "Client"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Client_cardCode_key" ON "Client"("cardCode");
-- CreateIndex
CREATE INDEX "Client_agencyId_idx" ON "Client"("agencyId");
-- CreateIndex
CREATE UNIQUE INDEX "ProviderCompany_inviteCode_key" ON "ProviderCompany"("inviteCode");
-- CreateIndex
CREATE UNIQUE INDEX "CompanyDispatcher_companyId_userId_key" ON "CompanyDispatcher"("companyId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "Provider_userId_key" ON "Provider"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Provider_inviteCode_key" ON "Provider"("inviteCode");
-- CreateIndex
CREATE INDEX "Provider_role_active_idx" ON "Provider"("role", "active");
-- CreateIndex
CREATE UNIQUE INDEX "District_code_key" ON "District"("code");
-- CreateIndex
CREATE INDEX "ProviderSchedule_providerId_weekday_idx" ON "ProviderSchedule"("providerId", "weekday");
-- CreateIndex
CREATE UNIQUE INDEX "Category_code_key" ON "Category"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Service_code_key" ON "Service"("code");
-- CreateIndex
CREATE UNIQUE INDEX "ChecklistItem_serviceId_code_key" ON "ChecklistItem"("serviceId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_code_key" ON "PaymentMethod"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "Order_scheduledAt_idx" ON "Order"("scheduledAt");
-- CreateIndex
CREATE INDEX "Order_clientId_idx" ON "Order"("clientId");
-- CreateIndex
CREATE INDEX "Order_providerId_scheduledAt_idx" ON "Order"("providerId", "scheduledAt");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_orderId_createdAt_idx" ON "OrderStatusHistory"("orderId", "createdAt");
-- CreateIndex
CREATE INDEX "OrderOffer_orderId_status_idx" ON "OrderOffer"("orderId", "status");
-- CreateIndex
CREATE INDEX "OrderOffer_providerId_status_idx" ON "OrderOffer"("providerId", "status");
-- CreateIndex
CREATE INDEX "OrderOffer_companyId_status_idx" ON "OrderOffer"("companyId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "OrderChecklistItem_fileId_key" ON "OrderChecklistItem"("fileId");
-- CreateIndex
CREATE UNIQUE INDEX "OrderChecklistItem_orderId_code_key" ON "OrderChecklistItem"("orderId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "OrderFile_storageKey_key" ON "OrderFile"("storageKey");
-- CreateIndex
CREATE INDEX "Message_orderId_createdAt_idx" ON "Message"("orderId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Rating_orderId_key" ON "Rating"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "LessonPackage_orderId_key" ON "LessonPackage"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "Agency_code_key" ON "Agency"("code");
-- CreateIndex
CREATE UNIQUE INDEX "AgencyCommission_orderId_key" ON "AgencyCommission"("orderId");
-- CreateIndex
CREATE INDEX "AgencyCommission_agencyId_period_idx" ON "AgencyCommission"("agencyId", "period");
-- CreateIndex
CREATE UNIQUE INDEX "PromoCode_code_key" ON "PromoCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PromoCodeUse_promoId_clientId_key" ON "PromoCodeUse"("promoId", "clientId");
-- CreateIndex
CREATE INDEX "Subscription_clientId_endsAt_idx" ON "Subscription"("clientId", "endsAt");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE UNIQUE INDEX "AdminSession_tokenHash_key" ON "AdminSession"("tokenHash");
-- CreateIndex
CREATE INDEX "AuditLog_entity_entityId_idx" ON "AuditLog"("entity", "entityId");
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CompanyDispatcher" ADD CONSTRAINT "CompanyDispatcher_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CompanyDispatcher" ADD CONSTRAINT "CompanyDispatcher_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProviderDistrict" ADD CONSTRAINT "ProviderDistrict_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProviderDistrict" ADD CONSTRAINT "ProviderDistrict_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProviderSchedule" ADD CONSTRAINT "ProviderSchedule_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_paymentMethodId_fkey" FOREIGN KEY ("paymentMethodId") REFERENCES "PaymentMethod"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderOffer" ADD CONSTRAINT "OrderOffer_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderOffer" ADD CONSTRAINT "OrderOffer_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderOffer" ADD CONSTRAINT "OrderOffer_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderChecklistItem" ADD CONSTRAINT "OrderChecklistItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderChecklistItem" ADD CONSTRAINT "OrderChecklistItem_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "OrderFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderFile" ADD CONSTRAINT "OrderFile_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileAccessLog" ADD CONSTRAINT "FileAccessLog_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "OrderFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Estimate" ADD CONSTRAINT "Estimate_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LessonPackage" ADD CONSTRAINT "LessonPackage_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorTask" ADD CONSTRAINT "OperatorTask_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgencyCommission" ADD CONSTRAINT "AgencyCommission_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgencyCommission" ADD CONSTRAINT "AgencyCommission_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromoCode" ADD CONSTRAINT "PromoCode_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromoCodeUse" ADD CONSTRAINT "PromoCodeUse_promoId_fkey" FOREIGN KEY ("promoId") REFERENCES "PromoCode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromoCodeUse" ADD CONSTRAINT "PromoCodeUse_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ProviderCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AdminSession" ADD CONSTRAINT "AdminSession_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+749
View File
@@ -0,0 +1,749 @@
// Barev Club — модель данных (PRD, раздел 7).
// Схема заложена сразу целиком; часть сущностей используется начиная с Этапов 2–3.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
}
datasource db {
provider = "postgresql"
}
// ───────────── Справочные перечисления ─────────────
enum Locale {
ru
en
zh
hy
es
}
/// Направление (раздел 2)
enum CategoryCode {
HOME
DOCS
LEARNING
PERKS
}
/// Сценарий заказа — определяет state machine и шаги исполнителя
enum ServiceFlow {
CLEANING
REPAIR
TRANSFER
LANGUAGE
CAREER
DOCUMENTS
}
enum ProviderRole {
CLEANER
HANDYMAN
DRIVER
TEACHER
CAREER_CONSULTANT
DOCS_AGENT
}
/// Все статусы всех сценариев. Допустимые переходы — в packages/core (flows).
enum OrderStatus {
NEW
ASSIGNED
EN_ROUTE
CLEANING
ON_SITE
ESTIMATE_APPROVED
REPAIRING
DRIVER_WAITING
RIDE_IN_PROGRESS
ENROLLED
LESSON_SCHEDULED
LESSON_IN_PROGRESS
LESSON_DONE
CONSULTANT_ASSIGNED
RESUME_RECEIVED
MEETING
PLAN_SENT
DOCS_REVIEW
NEEDS_MORE_DOCS
SUBMITTED
DONE
COMPLETED
CANCELLED
}
enum ActorType {
CLIENT
PROVIDER
DISPATCHER
ADMIN
SYSTEM
}
enum OfferStatus {
OFFERED
ACCEPTED
DECLINED
EXPIRED
REVOKED
}
enum PaymentStatus {
UNPAID
PAID
REFUNDED
}
enum RateType {
FIXED
PERCENT
}
enum AdminRole {
OWNER
OPERATOR
}
enum SubscriptionPlan {
BAREV_PLUS
}
enum SubscriptionSource {
AGENCY_GIFT
MANUAL
PAID
}
enum SubscriptionStatus {
ACTIVE
EXPIRED
CANCELLED
}
enum PromoKind {
AGENCY_GIFT
DISCOUNT
}
enum FileKind {
PHOTO_REPORT
CLIENT_PHOTO
DOCUMENT_SCAN
RESUME
OTHER
}
enum EstimateStatus {
PENDING
ACCEPTED
REJECTED
}
enum TaskStatus {
OPEN
DONE
}
enum PayoutStatus {
DRAFT
APPROVED
PAID
}
// ───────────── Пользователи ─────────────
/// Пользователь Telegram. Один и тот же человек может быть клиентом и исполнителем.
model User {
id String @id @default(cuid())
telegramId BigInt @unique
username String?
firstName String?
lastName String?
phone String?
locale Locale @default(ru)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client?
provider Provider?
dispatcher CompanyDispatcher[]
messages Message[]
}
model Client {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
consentAt DateTime?
consentVersion String?
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
agencyAttachedAt DateTime?
/// источник: deeplink | manual_code | organic
source String @default("organic")
utm Json?
/// подарочный месяц уже использован (один раз на клиента)
giftUsedAt DateTime?
/// короткий код клубной карты (Этап 3)
cardCode String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
subscriptions Subscription[]
ratings Rating[]
cardUsages CardUsage[]
promoUses PromoCodeUse[]
@@index([agencyId])
}
// ───────────── Исполнители ─────────────
model ProviderCompany {
id String @id @default(cuid())
name String
/// какие роли исполнителей есть в компании (какие услуги она берёт)
roles ProviderRole[]
/// договорная ставка компании
rateType RateType @default(PERCENT)
rateValue Int @default(70)
contactPhone String?
inviteCode String @unique
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employees Provider[]
dispatchers CompanyDispatcher[]
orders Order[]
offers OrderOffer[]
payouts Payout[]
}
model CompanyDispatcher {
id String @id @default(cuid())
companyId String
company ProviderCompany @relation(fields: [companyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@unique([companyId, userId])
}
model Provider {
id String @id @default(cuid())
/// заполняется, когда исполнитель открыл бота по инвайт-коду
userId String? @unique
user User? @relation(fields: [userId], references: [id])
name String
phone String?
role ProviderRole
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
languages Locale[]
/// ставка фрилансера; для сотрудников компании не используется
rateType RateType @default(PERCENT)
rateValue Int @default(70)
ratingSum Int @default(0)
ratingCount Int @default(0)
onShift Boolean @default(false)
active Boolean @default(true)
inviteCode String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
districts ProviderDistrict[]
schedule ProviderSchedule[]
orders Order[]
offers OrderOffer[]
ratings Rating[]
payouts Payout[]
@@index([role, active])
}
model District {
id String @id @default(cuid())
code String @unique
sortOrder Int @default(0)
providers ProviderDistrict[]
orders Order[]
}
model ProviderDistrict {
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
districtId String
district District @relation(fields: [districtId], references: [id], onDelete: Cascade)
@@id([providerId, districtId])
}
/// Недельный график: 0 = понедельник … 6 = воскресенье, минуты от полуночи по Asia/Yerevan
model ProviderSchedule {
id String @id @default(cuid())
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
weekday Int
startMinute Int
endMinute Int
@@index([providerId, weekday])
}
// ───────────── Каталог ─────────────
model Category {
id String @id @default(cuid())
code CategoryCode @unique
sortOrder Int @default(0)
/// скидка Barev+ на направление, %
barevPlusDiscountPct Int @default(0)
active Boolean @default(true)
services Service[]
}
model Service {
id String @id @default(cuid())
/// код услуги; тексты берутся из i18n по ключу services.<code>.*
code String @unique
categoryId String
category Category @relation(fields: [categoryId], references: [id])
flow ServiceFlow
providerRole ProviderRole
/// правила цены (см. packages/core/src/pricing)
pricing Json
/// описание параметров формы заказа (см. packages/core/src/params)
paramsSchema Json
/// окно записи и шаг слотов (см. packages/core/src/slots)
slotConfig Json
/// удержание при поздней отмене, %
lateCancelFeePct Int @default(30)
isOnline Boolean @default(false)
active Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checklist ChecklistItem[]
orders Order[]
}
/// Шаблон пункта чек-листа услуги
model ChecklistItem {
id String @id @default(cuid())
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
/// код пункта; подпись — i18n checklist.<code>
code String
requiresPhoto Boolean @default(false)
sortOrder Int @default(0)
@@unique([serviceId, code])
}
model PaymentMethod {
id String @id @default(cuid())
code String @unique
/// { ru, en, zh, hy, es }
name Json
active Boolean @default(true)
sortOrder Int @default(0)
orders Order[]
}
// ───────────── Заказы ─────────────
model Order {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
clientId String
client Client @relation(fields: [clientId], references: [id])
serviceId String
service Service @relation(fields: [serviceId], references: [id])
params Json
scheduledAt DateTime
durationMinutes Int
isOnline Boolean @default(false)
addressText String?
districtId String?
district District? @relation(fields: [districtId], references: [id])
lat Float?
lng Float?
note String?
/// язык клиента на момент заказа
locale Locale
basePrice Int
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
status OrderStatus @default(NEW)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
/// заказ передан компании; пока providerId пуст — «Назначен на компанию»
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
paymentMethodId String?
paymentMethod PaymentMethod? @relation(fields: [paymentMethodId], references: [id])
paymentStatus PaymentStatus @default(UNPAID)
paidAt DateTime?
cancelledAt DateTime?
cancelFee Int @default(0)
cancelReason String?
completedAt DateTime?
repeatOfId String?
reminded24hAt DateTime?
reminded1hAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
history OrderStatusHistory[]
checklist OrderChecklistItem[]
offers OrderOffer[]
files OrderFile[]
messages Message[]
estimates Estimate[]
rating Rating?
lessons LessonPackage?
payments Payment[]
commission AgencyCommission?
tasks OperatorTask[]
@@index([status])
@@index([scheduledAt])
@@index([clientId])
@@index([providerId, scheduledAt])
}
model OrderStatusHistory {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fromStatus OrderStatus?
toStatus OrderStatus
/// событие без смены статуса (например «Я на месте») или код действия
event String?
actorType ActorType
actorId String?
comment String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
/// Предложение заказа исполнителю или компании (принять / отклонить с таймаутом)
model OrderOffer {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
status OfferStatus @default(OFFERED)
expiresAt DateTime?
respondedAt DateTime?
createdById String?
createdAt DateTime @default(now())
@@index([orderId, status])
@@index([providerId, status])
@@index([companyId, status])
}
model OrderChecklistItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
code String
requiresPhoto Boolean @default(false)
sortOrder Int @default(0)
checkedAt DateTime?
fileId String? @unique
file OrderFile? @relation(fields: [fileId], references: [id])
@@unique([orderId, code])
}
model OrderFile {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
kind FileKind
storageKey String @unique
mime String
size Int
uploadedBy ActorType
uploadedById String?
/// дата автоудаления (документы — через N дней после завершения)
deleteAfter DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
checklistItem OrderChecklistItem?
accessLog FileAccessLog[]
}
model FileAccessLog {
id String @id @default(cuid())
fileId String
file OrderFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
actorType ActorType
actorId String?
createdAt DateTime @default(now())
}
/// Смета ремонта (Этап 2)
model Estimate {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
items Json
total Int
status EstimateStatus @default(PENDING)
respondedAt DateTime?
createdAt DateTime @default(now())
}
/// Чат клиент ↔ исполнитель через бота (Этап 2)
model Message {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fromType ActorType
fromUserId String?
fromUser User? @relation(fields: [fromUserId], references: [id])
text String?
fileId String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
model Rating {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
clientId String
client Client @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
score Int
comment String?
createdAt DateTime @default(now())
}
/// Пакет уроков (Этап 2)
model LessonPackage {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
total Int
completed Int @default(0)
}
model Payment {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
/// manual | <будущий провайдер>
provider String
amount Int
status PaymentStatus
externalId String?
markedById String?
createdAt DateTime @default(now())
}
model OperatorTask {
id String @id @default(cuid())
type String
orderId String?
order Order? @relation(fields: [orderId], references: [id], onDelete: Cascade)
status TaskStatus @default(OPEN)
note String?
createdAt DateTime @default(now())
doneAt DateTime?
}
// ───────────── Агентства, промокоды, членство ─────────────
model Agency {
id String @id @default(cuid())
/// код для deep-link и ручного ввода (латиница/цифры)
code String @unique
name String
/// вознаграждение, % от оплаченных заказов клиентов агентства
commissionPct Int @default(10)
contact String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
commissions AgencyCommission[]
promoCodes PromoCode[]
}
model AgencyCommission {
id String @id @default(cuid())
agencyId String
agency Agency @relation(fields: [agencyId], references: [id])
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
pct Int
amount Int
/// YYYY-MM
period String
createdAt DateTime @default(now())
@@index([agencyId, period])
}
model PromoCode {
id String @id @default(cuid())
code String @unique
kind PromoKind
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
discountPct Int?
maxUses Int?
usedCount Int @default(0)
validFrom DateTime?
validUntil DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
uses PromoCodeUse[]
}
model PromoCodeUse {
id String @id @default(cuid())
promoId String
promo PromoCode @relation(fields: [promoId], references: [id])
clientId String
client Client @relation(fields: [clientId], references: [id])
orderId String?
createdAt DateTime @default(now())
@@unique([promoId, clientId])
}
model Subscription {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
plan SubscriptionPlan @default(BAREV_PLUS)
source SubscriptionSource
status SubscriptionStatus @default(ACTIVE)
startsAt DateTime
endsAt DateTime
remindedAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, endsAt])
}
// ───────────── Партнёры и клубная карта (Этап 3) ─────────────
model Partner {
id String @id @default(cuid())
/// cafe | restaurant | other
category String
name String
/// { ru, en, ... }
offer Json
terms Json?
address String?
active Boolean @default(true)
createdAt DateTime @default(now())
usages CardUsage[]
}
model CardUsage {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
createdAt DateTime @default(now())
}
// ───────────── Финансы ─────────────
model Payout {
id String @id @default(cuid())
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
periodStart DateTime
periodEnd DateTime
amount Int
status PayoutStatus @default(DRAFT)
createdAt DateTime @default(now())
}
// ───────────── Админка ─────────────
model AdminUser {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role AdminRole @default(OPERATOR)
totpSecret String?
totpEnabled Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
sessions AdminSession[]
audit AuditLog[]
}
model AdminSession {
id String @id @default(cuid())
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// sha256 токена сессии
tokenHash String @unique
/// сессия прошла 2FA
verified Boolean @default(false)
expiresAt DateTime
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(cuid())
adminUserId String?
adminUser AdminUser? @relation(fields: [adminUserId], references: [id])
action String
entity String
entityId String?
data Json?
createdAt DateTime @default(now())
@@index([entity, entityId])
}
/// Настройки (ключ → значение). Список ключей и дефолты — packages/core/src/settings.ts
model Setting {
key String @id
value Json
updatedAt DateTime @updatedAt
}
/// Короткий токен для deep-link с лендинга, когда payload не влезает в 64 символа (UTM)
model StartToken {
id String @id
locale Locale?
agency String?
utm Json?
createdAt DateTime @default(now())
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient, type Prisma } from './generated/prisma/client.ts';
export * from './generated/prisma/client.ts';
export type Db = PrismaClient;
/** Клиент внутри транзакции */
export type Tx = Prisma.TransactionClient;
let instance: PrismaClient | undefined;
export function createDb(url = process.env.DATABASE_URL): PrismaClient {
if (!url) throw new Error('DATABASE_URL is not set');
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
}
/** Общий клиент процесса */
export function getDb(): PrismaClient {
instance ??= createDb();
return instance;
}
+351
View File
@@ -0,0 +1,351 @@
/**
* Seed для локальной разработки и staging: справочники, исполнители, компания, агентства,
* тестовые клиенты и заказы во всех колонках канбана. Полностью пересоздаёт данные.
*
* pnpm db:seed
*/
import path from 'node:path';
import { config } from 'dotenv';
import { hash } from '@node-rs/argon2';
import {
CATALOG,
DEFAULT_CATEGORY_DISCOUNTS,
DEFAULT_SETTINGS,
YEREVAN_DISTRICTS,
agencyCommission,
fromYerevan,
quote,
toYerevan,
yerevanMonthKey,
type CategoryCode,
} from '@barev/core';
import { createDb, type Locale, type OrderStatus } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
if (process.env.NODE_ENV === 'production' && process.env.SEED_FORCE !== '1') {
console.error('Seed пересоздаёт данные. В production запустите с SEED_FORCE=1, если уверены.');
process.exit(1);
}
const db = createDb();
const HOUR = 3600_000;
/** Telegram ID тестовых пользователей — ими можно войти в Mini App через pnpm dev:initdata */
export const SEED_TG = {
clientAnna: 100000001n,
clientJohn: 100000002n,
clientLi: 100000003n,
cleanerMaria: 200000001n,
cleanerAni: 200000002n,
cleanerElena: 200000003n,
companyLusine: 200000011n,
driverArman: 200000021n,
dispatcherKaren: 300000001n,
} as const;
async function wipe() {
const tables = await db.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
const list = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
if (list) await db.$executeRawUnsafe(`TRUNCATE ${list} RESTART IDENTITY CASCADE`);
}
/** Ереванское время: сегодня + dayOffset, hh:mm */
function at(dayOffset: number, hour: number, minute = 0): Date {
const p = toYerevan(new Date(Date.now() + dayOffset * 24 * HOUR));
return fromYerevan(p.year, p.month, p.day, hour * 60 + minute);
}
async function main() {
await wipe();
// ── настройки ──
await db.setting.createMany({ data: Object.entries(DEFAULT_SETTINGS).map(([key, value]) => ({ key, value })) });
// ── районы ──
await db.district.createMany({ data: YEREVAN_DISTRICTS.map((code, i) => ({ code, sortOrder: i })) });
const districts = Object.fromEntries((await db.district.findMany()).map((d) => [d.code, d.id])) as Record<(typeof YEREVAN_DISTRICTS)[number], string>;
// ── направления и услуги ──
const categoryOrder: CategoryCode[] = ['HOME', 'DOCS', 'LEARNING', 'PERKS'];
const categories: Record<string, string> = {};
for (const [i, code] of categoryOrder.entries()) {
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code] } });
categories[code] = c.id;
}
const services: Record<string, { id: string }> = {};
for (const [i, s] of CATALOG.entries()) {
services[s.code] = await db.service.create({
data: {
code: s.code,
categoryId: categories[s.category]!,
flow: s.flow,
providerRole: s.providerRole,
pricing: s.pricing,
paramsSchema: s.paramsSchema,
slotConfig: s.slotConfig,
isOnline: s.isOnline,
lateCancelFeePct: 30,
active: s.stage === 1,
sortOrder: i,
checklist: { create: s.checklist.map((c, j) => ({ code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: j })) },
},
});
}
// ── способы оплаты (PAY-1) ──
const cash = await db.paymentMethod.create({
data: { code: 'cash', sortOrder: 0, name: { ru: 'Наличными исполнителю', en: 'Cash to the specialist', zh: '现金支付给服务人员', hy: 'Կանխիկ՝ կատարողին', es: 'En efectivo al especialista' } },
});
await db.paymentMethod.create({
data: { code: 'transfer', sortOrder: 1, name: { ru: 'Перевод на карту', en: 'Card transfer', zh: '银行卡转账', hy: 'Փոխանցում քարտին', es: 'Transferencia a tarjeta' } },
});
// ── агентства ──
const domik = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik Realty', commissionPct: 10, contact: '+374 00 000000' } });
await db.agency.create({ data: { code: 'YERHOME', name: 'Yerevan Home', commissionPct: 10 } });
await db.agency.create({ data: { code: 'OLDAGENCY', name: 'Закрытое агентство', active: false } });
// ── админка ──
const owner = await db.adminUser.create({
data: {
email: process.env.SEED_OWNER_EMAIL ?? 'owner@barev.local',
name: 'Владелец',
role: 'OWNER',
passwordHash: await hash(process.env.SEED_OWNER_PASSWORD ?? 'barev-owner-123'),
},
});
await db.adminUser.create({
data: { email: 'operator@barev.local', name: 'Оператор', role: 'OPERATOR', passwordHash: await hash('barev-operator-123') },
});
// ── пользователи Telegram ──
const user = (telegramId: bigint, firstName: string, locale: Locale, lastName?: string) =>
db.user.create({ data: { telegramId, firstName, lastName, locale, username: `seed_${telegramId}` } });
// ── исполнители ──
const workweek = [0, 1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
const everyday = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 8 * 60, endMinute: 21 * 60 }));
async function provider(p: {
tg?: bigint;
name: string;
role: 'CLEANER' | 'DRIVER';
languages: Locale[];
districts: (keyof typeof districts)[];
companyId?: string;
rate?: { type: 'FIXED' | 'PERCENT'; value: number };
onShift?: boolean;
rating?: [number, number];
schedule?: typeof workweek;
invite: string;
}) {
const u = p.tg ? await user(p.tg, p.name.split(' ')[0]!, p.languages[0]!) : null;
return db.provider.create({
data: {
userId: u?.id,
name: p.name,
role: p.role,
companyId: p.companyId,
languages: p.languages,
rateType: p.rate?.type ?? 'PERCENT',
rateValue: p.rate?.value ?? 70,
onShift: p.onShift ?? true,
ratingSum: p.rating?.[0] ?? 0,
ratingCount: p.rating?.[1] ?? 0,
inviteCode: p.invite,
districts: { create: p.districts.map((d) => ({ districtId: districts[d] })) },
schedule: { create: p.schedule ?? workweek },
},
});
}
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], invite: 'PRO-MARIA' });
const ani = await provider({ tg: SEED_TG.cleanerAni, name: 'Ани Саргсян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['shengavit', 'erebuni', 'malatia_sebastia'], rate: { type: 'FIXED', value: 9000 }, rating: [22, 5], invite: 'PRO-ANI' });
const elena = await provider({ tg: SEED_TG.cleanerElena, name: 'Elena Gómez', role: 'CLEANER', languages: ['es', 'en', 'ru'], districts: ['kentron', 'arabkir', 'nor_nork'], schedule: everyday, invite: 'PRO-ELENA' });
await provider({ tg: SEED_TG.driverArman, name: 'Арман Мкртчян', role: 'DRIVER', languages: ['ru', 'hy', 'en'], districts: ['kentron'], schedule: everyday, rate: { type: 'FIXED', value: 7000 }, invite: 'PRO-ARMAN' });
const company = await db.providerCompany.create({
data: { name: 'Чисто Ереван', roles: ['CLEANER'], rateType: 'PERCENT', rateValue: 65, contactPhone: '+374 00 111111', inviteCode: 'CO-CHISTO' },
});
const karen = await user(SEED_TG.dispatcherKaren, 'Карен', 'ru');
await db.companyDispatcher.create({ data: { companyId: company.id, userId: karen.id } });
const lusine = await provider({ tg: SEED_TG.companyLusine, name: 'Лусине Акопян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['kentron', 'nor_nork', 'avan'], companyId: company.id, invite: 'PRO-LUSINE' });
await provider({ name: 'Гаяне Арутюнян', role: 'CLEANER', languages: ['ru', 'en'], districts: ['arabkir', 'ajapnyak', 'davtashen'], companyId: company.id, onShift: false, invite: 'PRO-GAYANE' });
// ── клиенты ──
const now = new Date();
async function client(tg: bigint, name: string, locale: Locale, opts: { agency?: string; gift?: boolean; card: string; attachedMonthsAgo?: number }) {
const u = await user(tg, name, locale);
const attachedAt = opts.agency ? new Date(now.getTime() - (opts.attachedMonthsAgo ?? 0) * 30 * 24 * HOUR - HOUR) : undefined;
const c = await db.client.create({
data: {
userId: u.id,
consentAt: now,
consentVersion: DEFAULT_SETTINGS['consent.version'],
agencyId: opts.agency,
agencyAttachedAt: attachedAt,
source: opts.agency ? 'deeplink' : 'organic',
utm: opts.agency ? { utm_source: 'qr', utm_campaign: 'lease' } : undefined,
cardCode: opts.card,
giftUsedAt: opts.gift ? attachedAt : undefined,
},
});
if (opts.gift) {
await db.subscription.create({
data: { clientId: c.id, source: 'AGENCY_GIFT', startsAt: attachedAt!, endsAt: new Date(attachedAt!.getTime() + 30 * 24 * HOUR) },
});
}
return c;
}
const anna = await client(SEED_TG.clientAnna, 'Анна', 'ru', { agency: domik.id, gift: true, card: 'ANNA2026' });
const john = await client(SEED_TG.clientJohn, 'John', 'en', { card: 'JOHN2026' });
const li = await client(SEED_TG.clientLi, '李明', 'zh', { agency: domik.id, gift: false, card: 'LIMING26' });
// ── заказы клининга ──
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
const checklistCodes = cleaning.checklist;
async function order(o: {
client: { id: string };
locale: Locale;
when: Date;
status: OrderStatus;
params: { cleaningType: string; rooms: number };
district: keyof typeof districts;
address: string;
providerId?: string;
companyId?: string;
discount: boolean;
checked?: number;
paid?: boolean;
note?: string;
history: [OrderStatus | null, OrderStatus, 'CLIENT' | 'SYSTEM' | 'PROVIDER' | 'ADMIN' | 'DISPATCHER', string?][];
}) {
const q = quote(cleaning.pricing, o.params, o.discount ? DEFAULT_CATEGORY_DISCOUNTS.HOME : 0);
const created = await db.order.create({
data: {
clientId: o.client.id,
serviceId: services.cleaning!.id,
params: o.params,
scheduledAt: o.when,
durationMinutes: q.durationMinutes,
addressText: o.address,
districtId: districts[o.district],
note: o.note,
locale: o.locale,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
status: o.status,
providerId: o.providerId,
companyId: o.companyId,
paymentMethodId: cash.id,
paymentStatus: o.paid ? 'PAID' : 'UNPAID',
paidAt: o.paid ? o.when : undefined,
completedAt: o.status === 'DONE' ? new Date(o.when.getTime() + q.durationMinutes * 60_000) : undefined,
cancelledAt: o.status === 'CANCELLED' ? now : undefined,
checklist: {
create: checklistCodes.map((c, i) => ({
code: c.code,
requiresPhoto: !!c.requiresPhoto,
sortOrder: i,
checkedAt: i < (o.checked ?? 0) && !c.requiresPhoto ? now : undefined,
})),
},
history: {
create: o.history.map(([from, to, actor, event], i) => ({
fromStatus: from,
toStatus: to,
actorType: actor,
event,
createdAt: new Date(now.getTime() - (o.history.length - i) * 600_000),
})),
},
},
});
return created;
}
// Новый, без исполнителя — колонка «Новые»
await order({
client: anna, locale: 'ru', when: at(1, 11), status: 'NEW', params: { cleaningType: 'general', rooms: 2 },
district: 'kentron', address: 'ул. Пушкина, 10, кв. 15', discount: true, note: 'Есть кот, он дружелюбный',
history: [[null, 'NEW', 'CLIENT']],
});
// Предложен Марии, ждёт подтверждения — колонка «Назначены»
const offered = await order({
client: john, locale: 'en', when: at(1, 15), status: 'NEW', params: { cleaningType: 'regular', rooms: 3 },
district: 'arabkir', address: 'Komitas Ave 25, apt 7', discount: false, history: [[null, 'NEW', 'CLIENT']],
});
await db.orderOffer.create({ data: { orderId: offered.id, providerId: maria.id, status: 'OFFERED', expiresAt: new Date(now.getTime() + 15 * 60_000), createdById: owner.id } });
// Передан компании — «Назначен на компанию»
const toCompany = await order({
client: li, locale: 'zh', when: at(2, 12), status: 'NEW', params: { cleaningType: 'renovation', rooms: 2 },
district: 'nor_nork', address: 'Gai Ave 3/1, apt 40', discount: false, history: [[null, 'NEW', 'CLIENT']],
});
await db.order.update({ where: { id: toCompany.id }, data: { companyId: company.id } });
await db.orderOffer.create({ data: { orderId: toCompany.id, companyId: company.id, status: 'OFFERED', createdById: owner.id } });
// Назначен Марии на сегодня — для демонстрации шагов исполнителя
const todayHour = Math.min(Math.max(toYerevan(now).hour + 2, 9), 17);
await order({
client: anna, locale: 'ru', when: at(0, todayHour), status: 'ASSIGNED', params: { cleaningType: 'regular', rooms: 2 },
district: 'kentron', address: 'ул. Абовяна, 22, кв. 5', providerId: maria.id, discount: true, note: 'Домофон 5#',
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted']],
});
// Идёт уборка у Елены — колонка «В работе»
await order({
client: li, locale: 'zh', when: at(0, Math.max(9, todayHour - 2)), status: 'CLEANING', params: { cleaningType: 'regular', rooms: 1 },
district: 'arabkir', address: 'Baghramyan Ave 50, apt 12', providerId: elena.id, discount: false, checked: 2,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted'], ['ASSIGNED', 'EN_ROUTE', 'PROVIDER', 'depart'], ['EN_ROUTE', 'EN_ROUTE', 'PROVIDER', 'arrived'], ['EN_ROUTE', 'CLEANING', 'PROVIDER', 'startCleaning']],
});
// Назначен Ани на послезавтра
await order({
client: john, locale: 'en', when: at(2, 10), status: 'ASSIGNED', params: { cleaningType: 'general', rooms: 1 },
district: 'shengavit', address: 'Arshakunyats Ave 8', providerId: ani.id, discount: false,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted']],
});
// Сотрудник компании — назначен диспетчером
await order({
client: anna, locale: 'ru', when: at(3, 14), status: 'ASSIGNED', params: { cleaningType: 'regular', rooms: 4 },
district: 'kentron', address: 'ул. Туманяна, 5', providerId: lusine.id, companyId: company.id, discount: true,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'DISPATCHER', 'dispatcherAssigned']],
});
// Готов и оплачен — с вознаграждением агентству
const done = await order({
client: anna, locale: 'ru', when: at(-3, 10), status: 'DONE', params: { cleaningType: 'general', rooms: 2 },
district: 'kentron', address: 'ул. Пушкина, 10, кв. 15', providerId: maria.id, discount: true, checked: 5, paid: true,
history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted'], ['ASSIGNED', 'EN_ROUTE', 'PROVIDER', 'depart'], ['EN_ROUTE', 'CLEANING', 'PROVIDER', 'startCleaning'], ['CLEANING', 'DONE', 'PROVIDER', 'finishCleaning']],
});
await db.payment.create({ data: { orderId: done.id, provider: 'manual', amount: done.totalPrice, status: 'PAID', markedById: owner.id } });
await db.agencyCommission.create({
data: { agencyId: domik.id, orderId: done.id, pct: domik.commissionPct, amount: agencyCommission(done.totalPrice, domik.commissionPct), period: yerevanMonthKey(done.scheduledAt) },
});
// Отменён клиентом
await order({
client: john, locale: 'en', when: at(4, 9), status: 'CANCELLED', params: { cleaningType: 'regular', rooms: 1 },
district: 'kentron', address: 'Amiryan St 4', discount: false, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'CANCELLED', 'CLIENT']],
});
// ── партнёры (Этап 3, для каталога) ──
await db.partner.createMany({
data: [
{ category: 'cafe', name: 'Coffee Lab', offer: { ru: '−10% на кофе', en: '−10% on coffee' }, address: 'Abovyan St 3' },
{ category: 'restaurant', name: 'Lavash', offer: { ru: 'Десерт в подарок', en: 'Free dessert' }, address: 'Tumanyan St 21' },
{ category: 'other', name: 'Yerevan Books', offer: { ru: '−15% на книги', en: '−15% on books' }, address: 'Nalbandyan St 7' },
],
});
console.log('Seed готов.');
console.log(` Админка: ${owner.email} / ${process.env.SEED_OWNER_PASSWORD ?? 'barev-owner-123'} (владелец), operator@barev.local / barev-operator-123`);
console.log(' Агентства: DOMIK, YERHOME (OLDAGENCY — неактивно)');
console.log(' Инвайты исполнителей: PRO-GAYANE (не привязан), компания CO-CHISTO');
console.log(' Telegram ID для pnpm dev:initdata:', Object.entries(SEED_TG).map(([k, v]) => `${k}=${v}`).join(', '));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => db.$disconnect());
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "prisma.config.ts"] }
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@barev/services",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*"
},
"devDependencies": {
"dotenv": "^17.0.0",
"vitest": "^4.1.0",
"pg": "^8.16.0",
"@types/pg": "^8.15.0"
}
}
+279
View File
@@ -0,0 +1,279 @@
import { averageRating, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core';
import type { Tx } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { transitionOrder } from './orders.ts';
const DAY_MS = 24 * 3600_000;
function interval(o: { scheduledAt: Date; durationMinutes: number }) {
return { start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) };
}
/**
* ADM-3: подсказка исполнителя. Частные исполнители и компании нужной роли;
* компания «свободна», если свободен хотя бы один её сотрудник.
*/
export async function assignmentCandidates(ctx: Ctx, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
const slot = interval(order);
const dayStart = startOfYerevanDay(order.scheduledAt);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const role = order.service.providerRole;
const [availability, providers, companies] = await Promise.all([
loadAvailability(ctx.db, role, dayStart, dayEnd, { excludeOrderId: order.id }),
ctx.db.provider.findMany({ where: { role, active: true }, include: { districts: true } }),
ctx.db.providerCompany.findMany({ where: { active: true, roles: { has: role } } }),
]);
const availById = new Map(availability.map((a) => [a.id, a]));
const loadOf = (id: string) => availById.get(id)?.busy.length ?? 0;
const freeOf = (id: string) => {
const a = availById.get(id);
return !!a && isProviderFree(a, slot);
};
const candidates: Candidate[] = providers
.filter((p) => !p.companyId)
.map((p) => ({
kind: 'provider',
id: p.id,
name: p.name,
languages: p.languages as Locale[],
districtIds: p.districts.map((d) => d.districtId),
load: loadOf(p.id),
rating: averageRating(p.ratingSum, p.ratingCount),
free: freeOf(p.id),
onShift: p.onShift,
}));
for (const c of companies) {
const staff = providers.filter((p) => p.companyId === c.id);
if (!staff.length) continue;
const free = staff.filter((p) => freeOf(p.id));
const ratings = staff.filter((p) => p.ratingCount > 0);
candidates.push({
kind: 'company',
id: c.id,
name: c.name,
languages: [...new Set(free.flatMap((p) => p.languages as Locale[]))],
districtIds: [...new Set(free.flatMap((p) => p.districts.map((d) => d.districtId)))],
load: Math.min(...staff.map((p) => loadOf(p.id))),
rating: ratings.length
? averageRating(
ratings.reduce((s, p) => s + p.ratingSum, 0),
ratings.reduce((s, p) => s + p.ratingCount, 0),
)
: null,
free: free.length > 0,
onShift: free.some((p) => p.onShift),
});
}
const ranked = rankCandidates(candidates, {
locale: order.locale as Locale,
districtId: order.districtId,
isOnline: order.isOnline,
});
const busy = candidates.filter((c) => !c.free);
return { ranked, busy };
}
async function revokeActiveOffers(tx: Tx, orderId: string) {
await tx.orderOffer.updateMany({ where: { orderId, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
}
/** Назначение из админки: частному исполнителю (с подтверждением) или компании (PRO-5, PRO-7) */
export async function offerOrder(
ctx: Ctx,
input: { orderId: string; adminId: string; target: { kind: 'provider' | 'company'; id: string } },
) {
const settings = await getSettings(ctx.db);
const timeout = settings['offer.acceptTimeoutMinutes'];
const events: DomainEvent[] = [];
const offer = await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.providerId) throw new DomainError('ALREADY_ASSIGNED');
if (input.target.kind === 'provider') {
const provider = await tx.provider.findUnique({ where: { id: input.target.id } });
if (!provider || !provider.active || provider.role !== order.service.providerRole) throw new DomainError('NOT_FOUND');
const [avail] = await loadAvailability(tx, provider.role, order.scheduledAt, interval(order).end, { excludeOrderId: order.id }).then((a) =>
a.filter((x) => x.id === provider.id),
);
if (!avail || !isProviderFree(avail, interval(order))) throw new DomainError('PROVIDER_BUSY');
await revokeActiveOffers(tx, order.id);
await tx.order.update({ where: { id: order.id }, data: { companyId: provider.companyId } });
const offer = await tx.orderOffer.create({
data: {
orderId: order.id,
providerId: provider.id,
status: 'OFFERED',
expiresAt: new Date(ctx.now().getTime() + timeout * 60_000),
createdById: input.adminId,
},
});
events.push({ type: 'offer.created', offerId: offer.id });
return offer;
}
const company = await tx.providerCompany.findUnique({ where: { id: input.target.id } });
if (!company || !company.active || !company.roles.includes(order.service.providerRole)) throw new DomainError('NOT_FOUND');
await revokeActiveOffers(tx, order.id);
await tx.order.update({ where: { id: order.id }, data: { companyId: company.id } });
// предложение компании без таймаута — заказ «Назначен на компанию», пока диспетчер не выберет сотрудника
const offer = await tx.orderOffer.create({
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: 'ADMIN', actorId: input.adminId, comment: company.name },
});
events.push({ type: 'company.order', orderId: order.id, companyId: company.id });
return offer;
});
for (const e of events) await ctx.notify.emit(e);
return offer;
}
/** Принять заказ исполнителем: проставляется исполнитель, статус → «Назначен» */
async function acceptOffer(ctx: Ctx, tx: Tx, offerId: string, providerId: string) {
const offer = await tx.orderOffer.findUnique({ where: { id: offerId }, include: { order: { include: { service: true } } } });
if (!offer || offer.providerId !== providerId || offer.status !== 'OFFERED') throw new DomainError('OFFER_NOT_ACTIVE');
if (offer.expiresAt && offer.expiresAt < ctx.now()) throw new DomainError('OFFER_NOT_ACTIVE');
const flow = getFlow(offer.order.service.flow);
await tx.orderOffer.update({ where: { id: offer.id }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
if (flow.assigned) {
const r = await transitionOrder(
ctx,
{ orderId: offer.orderId, to: flow.assigned, actor: 'SYSTEM', actorId: providerId, event: 'accepted', data: { providerId }, expectFrom: flow.initial },
tx,
);
return r.event;
}
await tx.order.update({ where: { id: offer.orderId }, data: { providerId } });
return null;
}
/**
* PRO-5: ответ исполнителя. Отказ (или таймаут) возвращает заказ в «Новые».
* Если исполнитель — сотрудник компании, заказ возвращается диспетчеру компании.
*/
export async function respondToOffer(ctx: Ctx, input: { offerId: string; providerId: string; accept: boolean }) {
const event = await ctx.db.$transaction(async (tx): Promise<DomainEvent | null> => {
const locked = await tx.orderOffer.findUnique({ where: { id: input.offerId } });
if (!locked) throw new DomainError('OFFER_NOT_ACTIVE');
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${locked.orderId} FOR UPDATE`;
if (input.accept) return acceptOffer(ctx, tx, input.offerId, input.providerId);
return declineOffer(ctx, tx, input.offerId, 'DECLINED', input.providerId);
});
if (event) await ctx.notify.emit(event);
}
async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED' | 'EXPIRED', providerId?: string): Promise<DomainEvent> {
const offer = await tx.orderOffer.findUnique({ where: { id: offerId }, include: { provider: true } });
if (!offer || offer.status !== 'OFFERED' || (providerId && offer.providerId !== providerId)) throw new DomainError('OFFER_NOT_ACTIVE');
await tx.orderOffer.update({ where: { id: offer.id }, data: { status, respondedAt: ctx.now() } });
const companyId = offer.provider?.companyId ?? null;
if (companyId) {
// сотрудник отказался — заказ снова у диспетчера компании
await tx.orderOffer.create({ data: { orderId: offer.orderId, companyId, status: 'OFFERED' } });
} else {
await tx.order.update({ where: { id: offer.orderId }, data: { companyId: null } });
}
await tx.orderStatusHistory.create({
data: {
orderId: offer.orderId,
toStatus: (await tx.order.findUniqueOrThrow({ where: { id: offer.orderId } })).status,
event: status === 'DECLINED' ? 'offerDeclined' : 'offerExpired',
actorType: status === 'DECLINED' ? 'PROVIDER' : 'SYSTEM',
actorId: offer.providerId,
},
});
return { type: status === 'DECLINED' ? 'offer.declined' : 'offer.expired', offerId: offer.id };
}
/** Воркер: просроченные предложения → как отказ */
export async function expireOffers(ctx: Ctx): Promise<number> {
const expired = await ctx.db.orderOffer.findMany({
where: { status: 'OFFERED', expiresAt: { lt: ctx.now() }, providerId: { not: null } },
select: { id: true },
});
let n = 0;
for (const { id } of expired) {
try {
const event = await ctx.db.$transaction((tx) => declineOffer(ctx, tx, id, 'EXPIRED'));
await ctx.notify.emit(event);
n++;
} catch (e) {
if (!(e instanceof DomainError)) throw e;
}
}
return n;
}
/**
* PRO-7: диспетчер компании выбирает сотрудника. Компания отвечает за своих людей,
* поэтому заказ сразу «Назначен», без отдельного подтверждения сотрудником.
*/
export async function dispatcherAssign(ctx: Ctx, input: { orderId: string; companyId: string; providerId: string; dispatcherUserId: string }) {
const event = await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const dispatcher = await tx.companyDispatcher.findUnique({
where: { companyId_userId: { companyId: input.companyId, userId: input.dispatcherUserId } },
});
if (!dispatcher) throw new DomainError('FORBIDDEN');
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
if (order.companyId !== input.companyId) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
if (order.providerId || order.status !== flow.initial) throw new DomainError('ALREADY_ASSIGNED');
const provider = await tx.provider.findUnique({ where: { id: input.providerId } });
if (!provider || provider.companyId !== input.companyId || !provider.active || provider.role !== order.service.providerRole) {
throw new DomainError('NOT_FOUND');
}
const [avail] = (await loadAvailability(tx, provider.role, order.scheduledAt, interval(order).end, { excludeOrderId: order.id })).filter(
(x) => x.id === provider.id,
);
if (!avail || !isProviderFree(avail, interval(order))) throw new DomainError('PROVIDER_BUSY');
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
if (flow.assigned) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.assigned, actor: 'DISPATCHER', actorId: input.dispatcherUserId, event: 'dispatcherAssigned', data: { providerId: provider.id } },
tx,
);
return r.event;
}
await tx.order.update({ where: { id: order.id }, data: { providerId: provider.id } });
return null;
});
if (event) await ctx.notify.emit(event);
}
/** Снять исполнителя (админ): заказ возвращается в «Новые» */
export async function unassignOrder(ctx: Ctx, input: { orderId: string; adminId: string }) {
const events: DomainEvent[] = [];
await ctx.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const flow = getFlow(order.service.flow);
await revokeActiveOffers(tx, order.id);
if (order.status === flow.assigned) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'unassigned', data: { providerId: null, companyId: null } },
tx,
);
events.push(r.event);
} else if (order.status === flow.initial) {
await tx.order.update({ where: { id: order.id }, data: { providerId: null, companyId: null } });
} else {
throw new DomainError('TRANSITION_NOT_ALLOWED');
}
if (order.providerId) events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId });
});
for (const e of events) await ctx.notify.emit(e);
}
+69
View File
@@ -0,0 +1,69 @@
import { daySlots, fromYerevan, parseDateKey, type Interval, type ProviderAvailability, type ProviderRole, type Slot, type SlotConfig } from '@barev/core';
import type { Db, Tx } from '@barev/db';
const ACTIVE_STATUSES_EXCLUDED = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
/** Исполнители роли с их графиком и занятостью на интервал [from, to) */
export async function loadAvailability(
db: Db | Tx,
role: ProviderRole,
from: Date,
to: Date,
opts: { excludeOrderId?: string; companyId?: string } = {},
): Promise<ProviderAvailability[]> {
const providers = await db.provider.findMany({
where: { role, active: true, ...(opts.companyId ? { companyId: opts.companyId } : {}) },
include: {
schedule: true,
orders: {
where: {
status: { notIn: [...ACTIVE_STATUSES_EXCLUDED] },
scheduledAt: { lt: to },
...(opts.excludeOrderId ? { id: { not: opts.excludeOrderId } } : {}),
},
select: { scheduledAt: true, durationMinutes: true },
},
},
});
return providers.map((p) => ({
id: p.id,
schedule: p.schedule,
busy: p.orders
.map((o) => ({ start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }))
.filter((i) => i.end > from),
}));
}
/**
* Неназначенные заказы роли (без исполнителя), занимающие мощность.
* Заказ, переданный компании, тоже ещё без исполнителя — он учитывается здесь.
*/
export async function loadUnassigned(db: Db | Tx, role: ProviderRole, from: Date, to: Date, excludeOrderId?: string): Promise<Interval[]> {
const orders = await db.order.findMany({
where: {
providerId: null,
status: { notIn: [...ACTIVE_STATUSES_EXCLUDED] },
service: { providerRole: role },
scheduledAt: { lt: to, gte: new Date(from.getTime() - 24 * 3600_000) },
...(excludeOrderId ? { id: { not: excludeOrderId } } : {}),
},
select: { scheduledAt: true, durationMinutes: true },
});
return orders
.map((o) => ({ start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }))
.filter((i) => i.end > from);
}
export async function slotsForDay(
db: Db | Tx,
input: { role: ProviderRole; config: SlotConfig; durationMinutes: number; dateKey: string; now: Date; excludeOrderId?: string },
): Promise<Slot[]> {
const { year, month, day } = parseDateKey(input.dateKey);
const from = fromYerevan(year, month, day);
const to = fromYerevan(year, month, day + 1);
const [providers, unassigned] = await Promise.all([
loadAvailability(db, input.role, from, to, { excludeOrderId: input.excludeOrderId }),
loadUnassigned(db, input.role, from, to, input.excludeOrderId),
]);
return daySlots({ ...input, providers, unassigned });
}
+72
View File
@@ -0,0 +1,72 @@
import {
fromPrice,
paramsSchemaSchema,
pricingSchema,
slotConfigSchema,
type CategoryCode,
type ParamsSchema,
type Pricing,
type SlotConfig,
} from '@barev/core';
import type { Db, Service, Tx } from '@barev/db';
import { DomainError } from './context.ts';
export interface ParsedService {
id: string;
code: string;
category: CategoryCode;
categoryDiscountPct: number;
flow: Service['flow'];
providerRole: Service['providerRole'];
pricing: Pricing;
paramsSchema: ParamsSchema;
slotConfig: SlotConfig;
lateCancelFeePct: number;
isOnline: boolean;
active: boolean;
fromPrice: number;
}
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number } };
export function parseService(s: ServiceWithCategory): ParsedService {
const pricing = pricingSchema.parse(s.pricing);
return {
id: s.id,
code: s.code,
category: s.category.code,
categoryDiscountPct: s.category.barevPlusDiscountPct,
flow: s.flow,
providerRole: s.providerRole,
pricing,
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
slotConfig: slotConfigSchema.parse(s.slotConfig),
lateCancelFeePct: s.lateCancelFeePct,
isOnline: s.isOnline,
active: s.active,
fromPrice: fromPrice(pricing),
};
}
/** Каталог для лендинга и Mini App: все направления, у услуг — «от N ֏» и признак доступности */
export async function listCatalog(db: Db) {
const categories = await db.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { services: { orderBy: { sortOrder: 'asc' }, include: { category: true } } },
});
return categories.map((c) => ({
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
services: c.services.map((s) => {
const p = parseService(s);
return { code: p.code, flow: p.flow, fromPrice: p.fromPrice, active: p.active, isOnline: p.isOnline };
}),
}));
}
export async function getServiceByCode(db: Db | Tx, code: string, opts: { activeOnly?: boolean } = {}): Promise<ParsedService> {
const s = await db.service.findUnique({ where: { code }, include: { category: true } });
if (!s) throw new DomainError('NOT_FOUND');
if (opts.activeOnly && !s.active) throw new DomainError('SERVICE_UNAVAILABLE');
return parseService(s);
}
+205
View File
@@ -0,0 +1,205 @@
import { randomBytes } from 'node:crypto';
import {
activeSubscription,
decideGift,
decodeStartPayload,
isLocale,
membershipLevel,
normalizeAgencyCode,
type Locale,
type MembershipLevel,
} from '@barev/core';
import type { Client, Db, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
export interface TelegramUserInput {
id: number | bigint;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
/** Код клубной карты: 8 символов без похожих букв */
const CARD_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export function generateCode(length = 8): string {
const bytes = randomBytes(length);
return Array.from(bytes, (b) => CARD_ALPHABET[b % CARD_ALPHABET.length]).join('');
}
/** Язык из Telegram, если он один из наших */
export function localeFromTelegram(code?: string): Locale | null {
const base = code?.split('-')[0]?.toLowerCase();
return isLocale(base) ? base : null;
}
export async function upsertUser(db: Db | Tx, tg: TelegramUserInput, locale?: Locale) {
const telegramId = BigInt(tg.id);
return db.user.upsert({
where: { telegramId },
create: {
telegramId,
firstName: tg.first_name,
lastName: tg.last_name,
username: tg.username,
locale: locale ?? localeFromTelegram(tg.language_code) ?? 'ru',
},
update: { firstName: tg.first_name, lastName: tg.last_name, username: tg.username },
});
}
export interface StartResult {
clientId: string;
isNew: boolean;
/** язык не пришёл в payload и не был выбран раньше — показать выбор (ONB-2) */
needsLocale: boolean;
needsConsent: boolean;
locale: Locale;
agencyName: string | null;
/** подарок активирован в этом /start */
gift: { endsAt: Date } | null;
}
/**
* ONB-1: /start с payload. Создаёт клиента, сохраняет источник (агентство, UTM),
* язык. Агентство закрепляется только при первом контакте — дальше не перезаписывается.
*/
export async function startClient(ctx: Ctx, tg: TelegramUserInput, rawPayload?: string): Promise<StartResult> {
const { db } = ctx;
const payload = decodeStartPayload(rawPayload);
let locale = payload.locale;
let agencyCode = payload.agency;
let utm: unknown = undefined;
if (payload.token) {
const token = await db.startToken.findUnique({ where: { id: payload.token } });
if (token) {
locale ??= token.locale ?? undefined;
agencyCode ??= token.agency ?? undefined;
utm = token.utm ?? undefined;
}
}
const agency = agencyCode ? await db.agency.findUnique({ where: { code: agencyCode } }) : null;
const usableAgency = agency?.active ? agency : null;
const existing = await db.user.findUnique({ where: { telegramId: BigInt(tg.id) }, include: { client: true } });
const user = await upsertUser(db, tg, locale);
if (locale && user.locale !== locale) await db.user.update({ where: { id: user.id }, data: { locale } });
let client = existing?.client ?? null;
const isNew = !client;
if (!client) {
client = await db.client.create({
data: {
userId: user.id,
cardCode: generateCode(),
source: usableAgency ? 'deeplink' : 'organic',
agencyId: usableAgency?.id,
agencyAttachedAt: usableAgency ? ctx.now() : undefined,
utm: utm ?? undefined,
},
});
} else if (!client.agencyId && usableAgency) {
client = await db.client.update({
where: { id: client.id },
data: { agencyId: usableAgency.id, agencyAttachedAt: ctx.now(), source: 'deeplink' },
});
}
// подарок выдаём сразу, если согласие уже есть; иначе — после согласия (giveConsent)
const gift = client.consentAt ? await activateAgencyGift(ctx, client.id) : null;
const finalAgency = client.agencyId ? await db.agency.findUnique({ where: { id: client.agencyId } }) : null;
return {
clientId: client.id,
isNew,
needsLocale: !locale && isNew,
needsConsent: !client.consentAt,
locale: locale ?? user.locale,
agencyName: finalAgency?.name ?? null,
gift,
};
}
export async function setLocale(db: Db, telegramId: bigint, locale: Locale) {
await db.user.update({ where: { telegramId }, data: { locale } });
}
/** ONB-3: согласие на обработку ПДн. После него — подарок агентства, если положен. */
export async function giveConsent(ctx: Ctx, clientId: string) {
const settings = await getSettings(ctx.db);
await ctx.db.client.update({
where: { id: clientId },
data: { consentAt: ctx.now(), consentVersion: settings['consent.version'] },
});
return activateAgencyGift(ctx, clientId);
}
/**
* ONB-4 / SUB-3: подарочный месяц Barev+ клиенту агентства, один раз.
* Возвращает дату окончания или null, если не положен.
*/
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date } | null> {
const { db } = ctx;
const settings = await getSettings(db);
const result = await db.$transaction(async (tx) => {
// блокируем строку клиента, чтобы параллельные /start не выдали подарок дважды
await tx.$queryRaw`SELECT id FROM "Client" WHERE id = ${clientId} FOR UPDATE`;
const client = await tx.client.findUniqueOrThrow({
where: { id: clientId },
include: { agency: true, subscriptions: true },
});
if (!client.agency || !client.consentAt) return null;
const now = ctx.now();
const current = activeSubscription(client.subscriptions, now);
const decision = decideGift({
now,
giftUsedAt: client.giftUsedAt,
agencyActive: client.agency.active,
giftDays: settings['barevPlus.giftDays'],
currentEndsAt: current?.endsAt ?? null,
});
if (!decision.ok) return null;
await tx.subscription.create({
data: { clientId, source: 'AGENCY_GIFT', startsAt: decision.startsAt, endsAt: decision.endsAt },
});
await tx.client.update({ where: { id: clientId }, data: { giftUsedAt: now } });
return { endsAt: decision.endsAt };
});
if (result) await ctx.notify.emit({ type: 'gift.activated', clientId, endsAt: result.endsAt.toISOString() });
return result;
}
/** ONB-6: ручной ввод кода агентства, если клиент пришёл без deep-link */
export async function applyAgencyCode(ctx: Ctx, clientId: string, rawCode: string) {
const code = normalizeAgencyCode(rawCode);
const agency = code ? await ctx.db.agency.findUnique({ where: { code } }) : null;
if (!agency || !agency.active) throw new DomainError('AGENCY_CODE_INVALID');
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId } });
if (client.agencyId && client.agencyId !== agency.id) throw new DomainError('AGENCY_ALREADY_SET');
if (!client.agencyId) {
await ctx.db.client.update({
where: { id: clientId },
data: { agencyId: agency.id, agencyAttachedAt: ctx.now(), source: 'manual_code' },
});
}
const gift = await activateAgencyGift(ctx, clientId);
return { agencyName: agency.name, gift };
}
export interface Membership {
level: MembershipLevel;
endsAt: Date | null;
}
export async function getMembership(db: Db | Tx, clientId: string, now: Date): Promise<Membership> {
const subs = await db.subscription.findMany({ where: { clientId, status: 'ACTIVE', endsAt: { gt: now } } });
const active = activeSubscription(subs, now);
return { level: membershipLevel(subs, now), endsAt: active?.endsAt ?? null };
}
export async function getClientByTelegramId(db: Db, telegramId: bigint): Promise<(Client & { user: { locale: Locale; firstName: string | null } }) | null> {
const user = await db.user.findUnique({ where: { telegramId }, include: { client: true } });
if (!user?.client) return null;
return { ...user.client, user: { locale: user.locale, firstName: user.firstName } };
}
+62
View File
@@ -0,0 +1,62 @@
import type { Db } from '@barev/db';
import type { OrderStatus } from '@barev/core';
/** Доменные события. Воркер превращает их в уведомления на языке получателя. */
export type DomainEvent =
| { type: 'order.created'; orderId: string }
| { type: 'order.status'; orderId: string; from: OrderStatus; to: OrderStatus }
/** событие без смены статуса, например «Я на месте» */
| { type: 'order.event'; orderId: string; code: string }
| { type: 'order.rescheduled'; orderId: string }
| { type: 'order.unassigned'; orderId: string; providerId: string }
| { type: 'offer.created'; offerId: string }
| { type: 'offer.declined'; offerId: string }
| { type: 'offer.expired'; offerId: string }
| { type: 'company.order'; orderId: string; companyId: string }
| { type: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
}
export interface Ctx {
db: Db;
notify: Notifier;
now: () => Date;
}
export const silentNotifier: Notifier = { emit: () => {} };
/** Ошибка бизнес-логики. code переводится на фронте как errors.<code>. */
export class DomainError extends Error {
constructor(
public code: DomainErrorCode,
public details?: Record<string, unknown>,
) {
super(code);
this.name = 'DomainError';
}
}
export type DomainErrorCode =
| 'NOT_FOUND'
| 'FORBIDDEN'
| 'CONSENT_REQUIRED'
| 'SERVICE_UNAVAILABLE'
| 'INVALID_PARAMS'
| 'SLOT_UNAVAILABLE'
| 'ADDRESS_REQUIRED'
| 'TRANSITION_NOT_ALLOWED'
| 'CHECKLIST_INCOMPLETE'
| 'PHOTO_REQUIRED'
| 'ESTIMATE_NOT_ACCEPTED'
| 'CANCEL_VIA_OPERATOR'
| 'RESCHEDULE_VIA_OPERATOR'
| 'AGENCY_CODE_INVALID'
| 'AGENCY_ALREADY_SET'
| 'OFFER_NOT_ACTIVE'
| 'PROVIDER_BUSY'
| 'ALREADY_ASSIGNED'
| 'INVITE_INVALID'
| 'NOT_PAID_STATE';
+10
View File
@@ -0,0 +1,10 @@
export * from './context.ts';
export * from './settings.ts';
export * from './catalog.ts';
export * from './clients.ts';
export * from './availability.ts';
export * from './orders.ts';
export * from './assignment.ts';
export * from './providers.ts';
export * from './queries.ts';
export * from './reminders.ts';
+410
View File
@@ -0,0 +1,410 @@
import {
agencyCommission,
cancelPolicy,
canRescheduleSelf,
checkTransition,
discountFor,
findActionTransition,
findEvent,
getFlow,
isAgencyAttributionActive,
isProviderFree,
isTerminal,
ManualPaymentProvider,
quote,
validateParams,
yerevanDateKey,
yerevanMonthKey,
type ActorType,
type GuardContext,
type OrderStatus,
} from '@barev/core';
import type { Db, Prisma, Tx } from '@barev/db';
import { loadAvailability, slotsForDay } from './availability.ts';
import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, rawParams);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const membership = await getMembership(ctx.db, clientId, ctx.now());
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct));
return { service, params: params.values, quote: q, membership };
}
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
const service = await getServiceByCode(ctx.db, input.serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const { durationMinutes } = quote(service.pricing, params.values);
return slotsForDay(ctx.db, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: input.dateKey,
now: ctx.now(),
excludeOrderId: input.excludeOrderId,
});
}
async function assertSlotFree(tx: Tx, service: ParsedService, scheduledAt: Date, durationMinutes: number, now: Date, excludeOrderId?: string) {
const slots = await slotsForDay(tx, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: yerevanDateKey(scheduledAt),
now,
excludeOrderId,
});
if (!slots.some((s) => s.start.getTime() === scheduledAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
}
export interface CreateOrderInput {
clientId: string;
serviceCode: string;
params: Record<string, unknown>;
scheduledAt: Date;
addressText?: string;
districtId?: string;
lat?: number;
lng?: number;
note?: string;
paymentMethodId?: string;
repeatOfId?: string;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
const order = await ctx.db.$transaction(
async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
data: {
clientId: input.clientId,
serviceId: service.id,
params,
scheduledAt: input.scheduledAt,
durationMinutes: q.durationMinutes,
isOnline: service.isOnline,
addressText: input.addressText?.trim(),
districtId: input.districtId,
lat: input.lat,
lng: input.lng,
note: input.note?.trim() || undefined,
locale: client.user.locale,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
status: flow.initial,
paymentMethodId: input.paymentMethodId,
repeatOfId: input.repeatOfId,
checklist: {
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
},
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
return order;
}
/** Контекст guard'ов для перехода — из чек-листа, сметы, пакета уроков */
export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardContext> {
const [items, estimate, lessons] = await Promise.all([
db.orderChecklistItem.findMany({ where: { orderId } }),
db.estimate.findFirst({ where: { orderId }, orderBy: { createdAt: 'desc' } }),
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
};
}
function transitionError(res: Exclude<ReturnType<typeof checkTransition>, { ok: true }>): DomainError {
if (res.code === 'GUARD_FAILED') {
if (res.guard === 'checklistComplete') return new DomainError('CHECKLIST_INCOMPLETE');
if (res.guard === 'estimateAccepted') return new DomainError('ESTIMATE_NOT_ACCEPTED');
}
return new DomainError('TRANSITION_NOT_ALLOWED', { reason: res.code, guard: 'guard' in res ? res.guard : undefined });
}
export interface TransitionInput {
orderId: string;
to: OrderStatus;
actor: ActorType;
actorId?: string;
comment?: string;
event?: string;
/** дополнительные поля заказа, которые меняются вместе со статусом */
data?: Prisma.OrderUncheckedUpdateInput;
/** ожидаемый текущий статус — защита от гонок двойного нажатия */
expectFrom?: OrderStatus;
}
/**
* Единственная точка смены статуса. Проверяет state machine на бэкенде,
* пишет историю (кто, когда, комментарий) и порождает событие для уведомления.
*/
export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?: Tx) {
const run = async (tx: Tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.expectFrom && order.status !== input.expectFrom) throw new DomainError('TRANSITION_NOT_ALLOWED', { reason: 'STALE' });
const res = checkTransition(order.service.flow, order.status, input.to, input.actor, await guardContext(tx, order.id));
if (!res.ok) throw transitionError(res);
const flow = getFlow(order.service.flow);
const updated = await tx.order.update({
where: { id: order.id },
data: {
...input.data,
status: input.to,
completedAt: flow.done.includes(input.to) ? ctx.now() : undefined,
cancelledAt: input.to === 'CANCELLED' ? ctx.now() : undefined,
},
});
await tx.orderStatusHistory.create({
data: {
orderId: order.id,
fromStatus: order.status,
toStatus: input.to,
event: input.event,
actorType: input.actor,
actorId: input.actorId,
comment: input.comment,
},
});
return { order: updated, from: order.status };
};
const result = txArg ? await run(txArg) : await ctx.db.$transaction(run);
const event: DomainEvent = { type: 'order.status', orderId: input.orderId, from: result.from, to: input.to };
if (txArg) return { ...result, event };
await ctx.notify.emit(event);
return { ...result, event };
}
/** Событие без смены статуса («Я на месте») — пишется в историю, клиент получает уведомление */
export async function recordEvent(ctx: Ctx, input: { orderId: string; code: string; actor: ActorType; actorId?: string }) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const ev = findEvent(order.service.flow, order.status, input.code);
if (!ev || !ev.by.includes(input.actor)) throw new DomainError('TRANSITION_NOT_ALLOWED');
await ctx.db.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.code, actorType: input.actor, actorId: input.actorId },
});
await ctx.notify.emit({ type: 'order.event', orderId: order.id, code: input.code });
}
/** PRO-3: кнопка исполнителя — переход или событие */
export async function providerAction(ctx: Ctx, input: { orderId: string; providerId: string; action: string; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
const t = findActionTransition(order.service.flow, order.status, input.action);
if (t) {
return transitionOrder(ctx, {
orderId: order.id,
to: t.to,
actor: 'PROVIDER',
actorId: input.providerId,
event: input.action,
comment: input.comment,
expectFrom: order.status,
});
}
await recordEvent(ctx, { orderId: order.id, code: input.action, actor: 'PROVIDER', actorId: input.providerId });
return null;
}
/** PRO-4: отметка пункта чек-листа. Пункт с обязательным фото не отмечается без файла. */
export async function setChecklistItem(
ctx: Ctx,
input: { orderId: string; providerId: string; code: string; checked: boolean; fileId?: string },
) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
const item = await ctx.db.orderChecklistItem.findUnique({ where: { orderId_code: { orderId: order.id, code: input.code } } });
if (!item) throw new DomainError('NOT_FOUND');
const fileId = input.fileId ?? item.fileId;
if (input.checked && item.requiresPhoto && !fileId) throw new DomainError('PHOTO_REQUIRED');
if (input.fileId) {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!file || file.orderId !== order.id) throw new DomainError('NOT_FOUND');
}
return ctx.db.orderChecklistItem.update({
where: { id: item.id },
data: { checkedAt: input.checked ? ctx.now() : null, fileId },
});
}
/** ORD-6: отмена клиентом. Внутри порога — с удержанием; после начала — только оператор. */
export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientId: string; reason?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const policy = cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
});
if (policy.kind === 'operator_only') throw new DomainError('CANCEL_VIA_OPERATOR');
const result = await ctx.db.$transaction(async (tx) => {
const r = await transitionOrder(
ctx,
{
orderId: order.id,
to: 'CANCELLED',
actor: 'CLIENT',
actorId: input.clientId,
comment: input.reason,
expectFrom: order.status,
data: { cancelFee: policy.fee, cancelReason: input.reason },
},
tx,
);
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'REVOKED' } });
return r;
});
await ctx.notify.emit(result.event);
return { order: result.order, fee: policy.fee };
}
/** Что увидит клиент, нажав «Отменить»: бесплатно / с удержанием / через оператора */
export async function cancelPreview(ctx: Ctx, orderId: string, clientId: string) {
const order = await ctx.db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
const cancellable = flow.clientCancellable.includes(order.status);
return {
cancellable,
policy: cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
}),
canReschedule: cancellable && canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours']),
freeHours: settings['cancel.freeHours'],
};
}
/**
* ORD-6: перенос клиентом — только вне порога. Если назначенный исполнитель занят в новое время,
* заказ возвращается в «Новые» и исполнитель получает уведомление.
*/
export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; clientId: string; scheduledAt: Date }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: { include: { category: true } } } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
if (!flow.clientCancellable.includes(order.status) || !canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours'])) {
throw new DomainError('RESCHEDULE_VIA_OPERATOR');
}
const service = await getServiceByCode(ctx.db, order.service.code);
const events: DomainEvent[] = [{ type: 'order.rescheduled', orderId: order.id }];
await ctx.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, order.durationMinutes, ctx.now(), order.id);
let keepProvider = false;
if (order.providerId) {
const end = new Date(input.scheduledAt.getTime() + order.durationMinutes * 60_000);
const [p] = (await loadAvailability(tx, service.providerRole, input.scheduledAt, end, { excludeOrderId: order.id })).filter(
(x) => x.id === order.providerId,
);
keepProvider = !!p && isProviderFree(p, { start: input.scheduledAt, end });
}
await tx.order.update({
where: { id: order.id },
data: { scheduledAt: input.scheduledAt, reminded24hAt: null, reminded1hAt: null },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'rescheduled', actorType: 'CLIENT', actorId: input.clientId },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.initial, actor: 'SYSTEM', event: 'rescheduled', data: { providerId: null } },
tx,
);
events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }, r.event);
}
});
for (const e of events) await ctx.notify.emit(e);
}
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
export async function repeatDraft(db: Db, orderId: string, clientId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
return {
serviceCode: order.service.code,
params: order.params as Record<string, unknown>,
addressText: order.addressText,
districtId: order.districtId,
note: order.note,
repeatOfId: order.id,
};
}
/** PAY-1: ручная отметка «оплачено». PAY-3: начисление агентству — только с оплаченных. */
export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: 'ADMIN' | 'PROVIDER'; id: string }; paymentMethodId?: string }) {
const provider = new ManualPaymentProvider();
const settings = await getSettings(ctx.db);
return ctx.db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { client: { include: { agency: true } } } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.by.type === 'PROVIDER' && order.providerId !== input.by.id) throw new DomainError('NOT_FOUND');
if (order.paymentStatus === 'PAID' || order.status === 'CANCELLED') throw new DomainError('NOT_PAID_STATE');
const res = await provider.confirmPayment(
{ orderId: order.id, amount: order.totalPrice, currency: 'AMD', description: `#${order.number}` },
{ markedBy: input.by.id },
);
const now = ctx.now();
await tx.payment.create({
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice, status: 'PAID', markedById: input.by.id },
});
const updated = await tx.order.update({
where: { id: order.id },
data: { paymentStatus: 'PAID', paidAt: now, paymentMethodId: input.paymentMethodId ?? order.paymentMethodId },
});
const agency = order.client.agency;
if (agency && isAgencyAttributionActive(order.client.agencyAttachedAt, order.createdAt, settings['agency.attributionMonths'])) {
await tx.agencyCommission.create({
data: {
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
return updated;
});
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {
return !isTerminal(flowCode, status);
}
+197
View File
@@ -0,0 +1,197 @@
import { averageRating, payoutFor, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
const DAY_MS = 24 * 3600_000;
/** Исполнитель или диспетчер открывает бота по инвайт-коду из админки */
export async function linkByInvite(ctx: Ctx, tg: TelegramUserInput, code: string) {
const user = await upsertUser(ctx.db, tg);
const provider = await ctx.db.provider.findUnique({ where: { inviteCode: code } });
if (provider) {
if (provider.userId && provider.userId !== user.id) throw new DomainError('INVITE_INVALID');
await ctx.db.provider.update({ where: { id: provider.id }, data: { userId: user.id } });
return { kind: 'provider' as const, name: provider.name };
}
const company = await ctx.db.providerCompany.findUnique({ where: { inviteCode: code } });
if (company) {
await ctx.db.companyDispatcher.upsert({
where: { companyId_userId: { companyId: company.id, userId: user.id } },
create: { companyId: company.id, userId: user.id },
update: {},
});
return { kind: 'dispatcher' as const, name: company.name };
}
throw new DomainError('INVITE_INVALID');
}
/** Кто этот пользователь в боте исполнителей */
export async function proIdentity(db: Db, telegramId: bigint) {
const user = await db.user.findUnique({
where: { telegramId },
include: {
provider: { include: { company: true } },
dispatcher: { include: { company: true } },
},
});
if (!user) return null;
const provider = user.provider?.active ? user.provider : null;
return {
user,
provider,
companies: user.dispatcher.filter((d) => d.company.active).map((d) => d.company),
};
}
export async function setShift(db: Db, providerId: string, onShift: boolean) {
return db.provider.update({ where: { id: providerId }, data: { onShift } });
}
/** Ставка, по которой считается заработок: у сотрудника компании — ставка компании */
function rateOf(p: { rateType: 'FIXED' | 'PERCENT'; rateValue: number; company: { rateType: 'FIXED' | 'PERCENT'; rateValue: number } | null }) {
return p.company ? { type: p.company.rateType, value: p.company.rateValue } : { type: p.rateType, value: p.rateValue };
}
/** PRO-1/PRO-2: шапка и заказы на сегодня */
export async function proToday(ctx: Ctx, providerId: string) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: dayStart, lt: dayEnd }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, checklist: { orderBy: { sortOrder: 'asc' } }, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.orderOffer.findMany({
where: { providerId, status: 'OFFERED', OR: [{ expiresAt: null }, { expiresAt: { gt: ctx.now() } }] },
include: { order: { include: { service: true, district: true, client: { include: { user: true } } } } },
orderBy: { createdAt: 'asc' },
}),
]);
const upcoming = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: dayEnd }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
take: 20,
});
const rate = rateOf(provider);
const doneToday = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
const current =
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status) && o.status !== getFlow(o.service.flow as ServiceFlow).assigned) ??
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status)) ??
null;
return {
provider: {
id: provider.id,
name: provider.name,
role: provider.role,
onShift: provider.onShift,
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
offers: offers.map((f) => ({ offerId: f.id, expiresAt: f.expiresAt, order: proOrderView(f.order) })),
};
}
type ProOrderRow = {
id: string;
number: number;
status: import('@barev/db').OrderStatus;
scheduledAt: Date;
durationMinutes: number;
isOnline: boolean;
addressText: string | null;
note: string | null;
locale: import('@barev/db').Locale;
params: unknown;
totalPrice: number;
paymentStatus: import('@barev/db').PaymentStatus;
service: { code: string; flow: import('@barev/db').ServiceFlow };
district: { code: string } | null;
client: { user: { firstName: string | null } };
checklist?: { code: string; requiresPhoto: boolean; checkedAt: Date | null; fileId: string | null }[];
};
/** Исполнитель видит имя, язык, адрес и заметку клиента — но не телефон и не Telegram */
export function proOrderView(o: ProOrderRow) {
return {
id: o.id,
number: o.number,
status: o.status,
flow: o.service.flow,
serviceCode: o.service.code,
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
addressText: o.addressText,
districtCode: o.district?.code ?? null,
note: o.note,
clientLocale: o.locale,
clientName: o.client.user.firstName,
params: o.params as Record<string, unknown>,
totalPrice: o.totalPrice,
paymentStatus: o.paymentStatus,
checklist: o.checklist?.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, checked: !!c.checkedAt, hasPhoto: !!c.fileId })) ?? [],
};
}
export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
const o = await ctx.db.order.findUnique({
where: { id: orderId },
include: { service: true, district: true, checklist: { orderBy: { sortOrder: 'asc' } }, client: { include: { user: true } } },
});
if (!o || o.providerId !== providerId) throw new DomainError('NOT_FOUND');
const flow = getFlow(o.service.flow as ServiceFlow);
return { ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status), checklistActive: flow.checklistAt.includes(o.status) };
}
/** PRO-6: история и заработок за период */
export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: Date) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'desc' },
});
const rate = rateOf(provider);
const done = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
return {
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? payoutFor(o.totalPrice, rate) : 0 })),
totalEarned: done.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
doneCount: done.length,
rate,
};
}
/** Диспетчер: заказы компании без исполнителя и сотрудники */
export async function companyBoard(ctx: Ctx, companyId: string, userId: string) {
const link = await ctx.db.companyDispatcher.findUnique({ where: { companyId_userId: { companyId, userId } } });
if (!link) throw new DomainError('FORBIDDEN');
const [company, pending, assigned, staff] = await Promise.all([
ctx.db.providerCompany.findUniqueOrThrow({ where: { id: companyId } }),
ctx.db.order.findMany({
where: { companyId, providerId: null, status: { not: 'CANCELLED' }, offers: { some: { companyId, status: 'OFFERED' } } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.order.findMany({
where: { companyId, providerId: { not: null }, scheduledAt: { gte: startOfYerevanDay(ctx.now()) }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, client: { include: { user: true } }, provider: true },
orderBy: { scheduledAt: 'asc' },
take: 50,
}),
ctx.db.provider.findMany({ where: { companyId, active: true }, orderBy: { name: 'asc' } }),
]);
return {
company: { id: company.id, name: company.name },
pending: pending.map(proOrderView),
assigned: assigned.map((o) => ({ ...proOrderView(o), providerName: o.provider?.name ?? null })),
staff: staff.map((p) => ({ id: p.id, name: p.name, role: p.role, onShift: p.onShift, languages: p.languages, linked: !!p.userId })),
};
}
+216
View File
@@ -0,0 +1,216 @@
import { getFlow, isProviderFree, isTerminal, kanbanColumn, startOfYerevanDay, type KanbanColumn, type ServiceFlow } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx } from './context.ts';
const DAY_MS = 24 * 3600_000;
const clientOrderInclude = {
service: { include: { category: true } },
district: true,
provider: { select: { name: true, ratingSum: true, ratingCount: true } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
paymentMethod: true,
} satisfies Prisma.OrderInclude;
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
/** Клиент видит имя исполнителя, но не его контакты (MSG-1) */
function clientOrderView(o: ClientOrderRow) {
const flow = o.service.flow as ServiceFlow;
return {
id: o.id,
number: o.number,
serviceCode: o.service.code,
category: o.service.category.code,
flow,
status: o.status,
active: !isTerminal(flow, o.status),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
addressText: o.addressText,
districtCode: o.district?.code ?? null,
note: o.note,
params: o.params as Record<string, unknown>,
basePrice: o.basePrice,
discountPct: o.discountPct,
discountAmount: o.discountAmount,
totalPrice: o.totalPrice,
cancelFee: o.cancelFee,
paymentStatus: o.paymentStatus,
paymentMethod: o.paymentMethod ? { code: o.paymentMethod.code, name: o.paymentMethod.name as Record<string, string> } : null,
providerName: o.provider?.name ?? null,
history: o.history.map((h) => ({ from: h.fromStatus, to: h.toStatus, event: h.event, at: h.createdAt })),
photos: o.checklist.filter((c) => c.fileId).map((c) => ({ code: c.code, fileId: c.fileId! })),
/** основной путь сценария — для шкалы прогресса */
steps: getFlow(flow).steps,
};
}
export async function listClientOrders(db: Db, clientId: string) {
const orders = await db.order.findMany({
where: { clientId },
include: clientOrderInclude,
orderBy: { scheduledAt: 'desc' },
take: 100,
});
const views = orders.map(clientOrderView);
return {
active: views.filter((o) => o.active).sort((a, b) => +a.scheduledAt - +b.scheduledAt),
history: views.filter((o) => !o.active),
};
}
export async function clientOrder(db: Db, clientId: string, orderId: string) {
const o = await db.order.findUnique({ where: { id: orderId }, include: clientOrderInclude });
if (!o || o.clientId !== clientId) throw new DomainError('NOT_FOUND');
return clientOrderView(o);
}
// ───────────── Админка ─────────────
export interface KanbanFilter {
category?: string;
serviceCode?: string;
dateFrom?: Date;
dateTo?: Date;
search?: string;
includeCancelled?: boolean;
}
/** ADM-1: канбан. Готовые и отменённые — только за последние 7 дней, если не задан период. */
export async function kanban(ctx: Ctx, f: KanbanFilter) {
const where: Prisma.OrderWhereInput = {
...(f.serviceCode ? { service: { code: f.serviceCode } } : f.category ? { service: { category: { code: f.category as never } } } : {}),
...(f.dateFrom || f.dateTo ? { scheduledAt: { gte: f.dateFrom, lt: f.dateTo } } : {}),
};
if (f.search?.trim()) {
const q = f.search.trim();
const n = Number(q.replace(/^#/, ''));
where.OR = [
...(Number.isInteger(n) && n > 0 ? [{ number: n }] : []),
{ addressText: { contains: q, mode: 'insensitive' as const } },
{ client: { user: { OR: [{ firstName: { contains: q, mode: 'insensitive' as const } }, { username: { contains: q, mode: 'insensitive' as const } }, { phone: { contains: q } }] } } },
{ provider: { name: { contains: q, mode: 'insensitive' as const } } },
];
}
if (!f.dateFrom && !f.dateTo) {
const weekAgo = new Date(ctx.now().getTime() - 7 * DAY_MS);
where.AND = [{ OR: [{ status: { notIn: ['DONE', 'COMPLETED', 'PLAN_SENT', 'CANCELLED'] } }, { updatedAt: { gte: weekAgo } }] }];
}
const orders = await ctx.db.order.findMany({
where,
include: {
service: { include: { category: true } },
district: true,
provider: { select: { id: true, name: true } },
company: { select: { id: true, name: true } },
client: { include: { user: true } },
offers: { where: { status: 'OFFERED' }, include: { provider: { select: { name: true } } } },
},
orderBy: { scheduledAt: 'asc' },
take: 500,
});
const columns: Record<KanbanColumn, ReturnType<typeof card>[]> = { new: [], assigned: [], in_progress: [], done: [], cancelled: [] };
function card(o: (typeof orders)[number]) {
const offer = o.offers[0];
return {
id: o.id,
number: o.number,
serviceCode: o.service.code,
category: o.service.category.code,
status: o.status,
scheduledAt: o.scheduledAt,
districtCode: o.district?.code ?? null,
clientName: o.client.user.firstName,
clientLocale: o.locale,
totalPrice: o.totalPrice,
paymentStatus: o.paymentStatus,
providerName: o.provider?.name ?? null,
companyName: o.company?.name ?? null,
/** «Ждёт подтверждения: Мария» / «Назначен на компанию» */
pending: offer ? (offer.providerId ? { kind: 'provider' as const, name: offer.provider?.name ?? '' } : { kind: 'company' as const, name: o.company?.name ?? '' }) : null,
};
}
for (const o of orders) {
const col = kanbanColumn(o.service.flow as ServiceFlow, o.status, {
hasProvider: !!o.providerId,
hasPendingOffer: o.offers.length > 0,
hasCompany: !!o.companyId,
});
if (col === 'cancelled' && !f.includeCancelled) continue;
columns[col].push(card(o));
}
return columns;
}
/** ADM-2: сводка за сегодня */
export async function summary(ctx: Ctx) {
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [today, unassigned, paidToday, providers, openTasks] = await Promise.all([
ctx.db.order.count({ where: { scheduledAt: { gte: dayStart, lt: dayEnd }, status: { not: 'CANCELLED' } } }),
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
ctx.db.order.aggregate({ _sum: { totalPrice: true }, where: { paidAt: { gte: dayStart, lt: dayEnd }, paymentStatus: 'PAID' } }),
ctx.db.provider.findMany({ where: { active: true }, include: { schedule: true } }),
ctx.db.operatorTask.count({ where: { status: 'OPEN' } }),
]);
// свободен сейчас: на смене и нет заказа, который идёт прямо сейчас
const now = ctx.now();
const busyNow = await ctx.db.order.findMany({
where: { providerId: { not: null }, scheduledAt: { lte: now, gte: new Date(now.getTime() - DAY_MS) }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] } },
select: { providerId: true, scheduledAt: true, durationMinutes: true },
});
const busyIds = new Set(busyNow.filter((o) => o.scheduledAt.getTime() + o.durationMinutes * 60_000 > now.getTime()).map((o) => o.providerId));
const freeProviders = providers.filter((p) => p.onShift && !busyIds.has(p.id)).length;
return { ordersToday: today, unassigned, revenueToday: paidToday._sum.totalPrice ?? 0, freeProviders, providersOnShift: providers.filter((p) => p.onShift).length, openTasks };
}
/** ADM-4: карточка заказа */
export async function adminOrder(db: Db, orderId: string) {
const o = await db.order.findUnique({
where: { id: orderId },
include: {
service: { include: { category: true } },
district: true,
provider: true,
company: true,
client: { include: { user: true, agency: true } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
offers: { orderBy: { createdAt: 'desc' }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
files: { where: { deletedAt: null } },
messages: { orderBy: { createdAt: 'asc' } },
paymentMethod: true,
payments: { orderBy: { createdAt: 'desc' } },
},
});
if (!o) throw new DomainError('NOT_FOUND');
return o;
}
/** Сотрудники компании и свободны ли они на время заказа — для диспетчера */
export async function companyStaffForOrder(ctx: Ctx, companyId: string, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
if (order.companyId !== companyId) throw new DomainError('NOT_FOUND');
const end = new Date(order.scheduledAt.getTime() + order.durationMinutes * 60_000);
const avail = await loadAvailability(ctx.db, order.service.providerRole, startOfYerevanDay(order.scheduledAt), new Date(startOfYerevanDay(order.scheduledAt).getTime() + DAY_MS), {
companyId,
excludeOrderId: order.id,
});
const staff = await ctx.db.provider.findMany({ where: { companyId, active: true, role: order.service.providerRole }, orderBy: { name: 'asc' } });
return staff.map((p) => {
const a = avail.find((x) => x.id === p.id);
return {
id: p.id,
name: p.name,
languages: p.languages,
onShift: p.onShift,
free: !!a && isProviderFree(a, { start: order.scheduledAt, end }),
load: a?.busy.length ?? 0,
speaksClientLanguage: p.languages.includes(order.locale),
};
});
}
+34
View File
@@ -0,0 +1,34 @@
import { HOUR } from '@barev/core';
import type { Ctx } from './context.ts';
const FINAL = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
/**
* MSG-2: напоминания за 24 часа и за 1 час. Отметка reminded*At делает обработку идемпотентной:
* воркер может запускать её каждую минуту. Заказы, созданные позже чем за 24 ч, получают только часовое.
*/
export async function dueReminders(ctx: Ctx): Promise<number> {
const now = ctx.now();
let sent = 0;
for (const hours of [24, 1] as const) {
const field = hours === 24 ? 'reminded24hAt' : 'reminded1hAt';
const due = await ctx.db.order.findMany({
where: {
status: { notIn: [...FINAL] },
[field]: null,
scheduledAt: { gt: now, lte: new Date(now.getTime() + hours * HOUR) },
// 24-часовое не шлём, если до начала уже меньше часа — придёт часовое
...(hours === 24 ? { NOT: { scheduledAt: { lte: new Date(now.getTime() + HOUR) } } } : {}),
},
select: { id: true },
});
for (const { id } of due) {
const { count } = await ctx.db.order.updateMany({ where: { id, [field]: null }, data: { [field]: now } });
if (count === 1) {
await ctx.notify.emit({ type: 'order.reminder', orderId: id, hours });
sent++;
}
}
}
return sent;
}
+11
View File
@@ -0,0 +1,11 @@
import { resolveSettings, settingsSchema, type SettingKey, type Settings } from '@barev/core';
import type { Db, Tx } from '@barev/db';
export async function getSettings(db: Db | Tx): Promise<Settings> {
return resolveSettings(await db.setting.findMany());
}
export async function setSetting<K extends SettingKey>(db: Db, key: K, value: Settings[K]): Promise<void> {
const parsed = settingsSchema.shape[key].parse(value);
await db.setting.upsert({ where: { key }, create: { key, value: parsed }, update: { value: parsed } });
}
+327
View File
@@ -0,0 +1,327 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { encodeStartPayload } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
availableSlots,
cancelByClient,
createOrder,
dispatcherAssign,
dueReminders,
expireOffers,
giveConsent,
kanban,
markPaid,
offerOrder,
providerAction,
quoteForClient,
respondToOffer,
rescheduleByClient,
setChecklistItem,
startClient,
transitionOrder,
DomainError,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
async function onboardedClient(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, payload?: string) {
const start = await startClient(ctx, tgUser(id), payload);
await giveConsent(ctx, start.clientId);
return start.clientId;
}
async function newOrder(ctx: ReturnType<typeof makeCtx>['ctx'], clientId: string, when = slotAt(2, 11)) {
return createOrder(ctx, {
clientId,
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2 },
scheduledAt: when,
addressText: 'Pushkin 10',
districtId: fx.kentron.id,
});
}
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
describe('онбординг (ONB-1…6)', () => {
it('/start с кодом агентства: клиент с источником, язык, подарок после согласия', async () => {
const { ctx, events } = makeCtx(NOW);
const start = await startClient(ctx, tgUser(1, 'en'), encodeStartPayload({ locale: 'hy', agency: 'domik' }));
expect(start).toMatchObject({ isNew: true, needsLocale: false, needsConsent: true, locale: 'hy', agencyName: 'Domik', gift: null });
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
expect(client.agencyId).toBe(fx.agency.id);
expect(client.source).toBe('deeplink');
expect(client.user.locale).toBe('hy');
const gift = await giveConsent(ctx, start.clientId);
expect(gift?.endsAt.getTime()).toBe(NOW.getTime() + 30 * 24 * 3600_000);
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
});
it('подарок — один раз; повторный /start с другим агентством не перезаписывает источник', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 2, encodeStartPayload({ agency: 'DOMIK' }));
await db.agency.create({ data: { code: 'OTHER', name: 'Other' } });
const again = await startClient(ctx, tgUser(2), encodeStartPayload({ agency: 'OTHER' }));
expect(again).toMatchObject({ isNew: false, agencyName: 'Domik', gift: null });
expect(await db.subscription.count({ where: { clientId: id } })).toBe(1);
});
it('без языка в payload и у нового клиента — спрашиваем язык', async () => {
const { ctx } = makeCtx(NOW);
expect((await startClient(ctx, tgUser(3, 'de'))).needsLocale).toBe(true);
});
it('UTM через короткий токен', async () => {
const { ctx } = makeCtx(NOW);
await db.startToken.create({ data: { id: 'tok1', locale: 'es', agency: 'DOMIK', utm: { utm_source: 'qr' } } });
const s = await startClient(ctx, tgUser(4), encodeStartPayload({ token: 'tok1' }));
expect(s.locale).toBe('es');
const c = await db.client.findUniqueOrThrow({ where: { id: s.clientId } });
expect(c.utm).toEqual({ utm_source: 'qr' });
expect(c.agencyId).toBe(fx.agency.id);
});
it('ручной ввод кода (ONB-6): неверный/неактивный — ошибка, верный — подарок', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 5);
expect(await code(applyAgencyCode(ctx, id, 'nope'))).toBe('AGENCY_CODE_INVALID');
expect(await code(applyAgencyCode(ctx, id, 'closed'))).toBe('AGENCY_CODE_INVALID');
const r = await applyAgencyCode(ctx, id, ' domik ');
expect(r.gift).not.toBeNull();
expect((await db.client.findUniqueOrThrow({ where: { id } })).source).toBe('manual_code');
});
});
describe('заказ (ORD-3…6)', () => {
it('цена со скидкой Barev+ только у подписчика', async () => {
const { ctx } = makeCtx(NOW);
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
const plain = await onboardedClient(ctx, 11);
const p = { cleaningType: 'regular', rooms: 2 };
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toEqual({ basePrice: 19000, discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
});
it('без согласия заказ не создаётся; без адреса — тоже', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(12));
expect(await code(newOrder(ctx, s.clientId))).toBe('CONSENT_REQUIRED');
await giveConsent(ctx, s.clientId);
expect(
await code(createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 1 }, scheduledAt: slotAt(2, 11) })),
).toBe('ADDRESS_REQUIRED');
});
it('создаёт «Новый» с чек-листом и историей, шлёт событие', async () => {
const { ctx, events } = makeCtx(NOW);
const id = await onboardedClient(ctx, 13);
const order = await newOrder(ctx, id);
expect(order.status).toBe('NEW');
expect(await db.orderChecklistItem.count({ where: { orderId: order.id } })).toBe(5);
expect(await db.orderStatusHistory.count({ where: { orderId: order.id } })).toBe(1);
expect(events).toContainEqual({ type: 'order.created', orderId: order.id });
const cols = await kanban(ctx, {});
expect(cols.new.map((c) => c.id)).toContain(order.id);
});
it('слот не продаётся больше раз, чем есть свободных исполнителей', async () => {
const { ctx } = makeCtx(NOW);
const when = slotAt(2, 11);
// 3 клинера (Maria, Ani, Lusine) → 3 заказа, четвёртый — отказ
for (let i = 0; i < 3; i++) await newOrder(ctx, await onboardedClient(ctx, 20 + i), when);
expect(await code(newOrder(ctx, await onboardedClient(ctx, 30), when))).toBe('SLOT_UNAVAILABLE');
const slots = await availableSlots(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, dateKey: '2026-10-21' });
expect(slots.some((s) => s.start.getTime() === when.getTime())).toBe(false);
});
it('время вне окна или в прошлом — отказ', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 31);
expect(await code(newOrder(ctx, id, slotAt(0, 9)))).toBe('SLOT_UNAVAILABLE');
expect(await code(newOrder(ctx, id, slotAt(2, 19)))).toBe('SLOT_UNAVAILABLE');
});
it('отмена: бесплатно за 24 ч, позже — удержание 30%, после начала — только оператор', async () => {
const { ctx, clock } = makeCtx(NOW);
const id = await onboardedClient(ctx, 32);
const free = await newOrder(ctx, id, slotAt(2, 11));
expect((await cancelByClient(ctx, { orderId: free.id, clientId: id })).fee).toBe(0);
const late = await newOrder(ctx, id, slotAt(1, 9));
clock.now = new Date(late.scheduledAt.getTime() - 2 * 3600_000);
expect((await cancelByClient(ctx, { orderId: late.id, clientId: id })).fee).toBe(Math.floor(late.totalPrice * 0.3));
clock.now = NOW;
const started = await newOrder(ctx, id, slotAt(1, 12));
clock.now = new Date(started.scheduledAt.getTime() + 60_000);
expect(await code(cancelByClient(ctx, { orderId: started.id, clientId: id }))).toBe('CANCEL_VIA_OPERATOR');
});
it('чужой заказ отменить нельзя', async () => {
const { ctx } = makeCtx(NOW);
const a = await onboardedClient(ctx, 33);
const b = await onboardedClient(ctx, 34);
const o = await newOrder(ctx, a);
expect(await code(cancelByClient(ctx, { orderId: o.id, clientId: b }))).toBe('NOT_FOUND');
});
});
describe('назначение (ADM-3, PRO-5, PRO-7)', () => {
it('подсказка учитывает язык клиента', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(40), encodeStartPayload({ locale: 'hy' }));
await giveConsent(ctx, s.clientId);
const o = await newOrder(ctx, s.clientId);
const { ranked } = await assignmentCandidates(ctx, o.id);
expect(ranked[0]).toMatchObject({ kind: 'provider', name: 'Ani' });
expect(ranked.map((r) => r.name)).toContain('Chisto');
});
it('предложение → принятие → «Назначен», клиент получает событие', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 41));
const offer = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'provider', name: 'Maria' });
expect(await code(respondToOffer(ctx, { offerId: offer.id, providerId: fx.ani.id, accept: true }))).toBe('OFFER_NOT_ACTIVE');
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
const updated = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(updated).toMatchObject({ status: 'ASSIGNED', providerId: fx.maria.id });
expect(events).toContainEqual({ type: 'order.status', orderId: o.id, from: 'NEW', to: 'ASSIGNED' });
});
it('отказ и таймаут возвращают заказ в «Новые»', async () => {
const { ctx, clock } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 42));
const f1 = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f1.id, providerId: fx.maria.id, accept: false });
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.ani.id } });
clock.now = new Date(NOW.getTime() + 16 * 60_000);
expect(await expireOffers(ctx)).toBe(1);
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('NEW');
});
it('компания: «Назначен на компанию» → диспетчер выбирает сотрудника', async () => {
const { ctx } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 43));
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'company', id: fx.company.id } });
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'company', name: 'Chisto' });
// чужой сотрудник и не-диспетчер — отказ
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.maria.id, dispatcherUserId: fx.dispatcher.id }))).toBe('NOT_FOUND');
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: 'x' }))).toBe('FORBIDDEN');
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'ASSIGNED', providerId: fx.lusine.id, companyId: fx.company.id });
const hist = await db.orderStatusHistory.findFirst({ where: { orderId: o.id, toStatus: 'ASSIGNED' } });
expect(hist?.actorType).toBe('DISPATCHER');
});
it('занятому исполнителю предложить нельзя', async () => {
const { ctx } = makeCtx(NOW);
const when = slotAt(2, 11);
const a = await newOrder(ctx, await onboardedClient(ctx, 44), when);
const f = await offerOrder(ctx, { orderId: a.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
const b = await newOrder(ctx, await onboardedClient(ctx, 45), when);
expect(await code(offerOrder(ctx, { orderId: b.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } }))).toBe('PROVIDER_BUSY');
});
});
describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
async function assigned(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const o = await newOrder(ctx, await onboardedClient(ctx, tg));
const f = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
return o;
}
it('шаги клинера; закрыть без чек-листа и фото нельзя', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await assigned(ctx, 50);
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' }))).toBe('TRANSITION_NOT_ALLOWED');
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'depart' });
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'arrived' });
expect(events).toContainEqual({ type: 'order.event', orderId: o.id, code: 'arrived' });
// чек-лист до начала уборки не отмечается
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'kitchen', checked: true }))).toBe('TRANSITION_NOT_ALLOWED');
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' });
for (const c of ['kitchen', 'bathroom', 'rooms', 'floors']) await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: c, checked: true });
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' }))).toBe('CHECKLIST_INCOMPLETE');
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true }))).toBe('PHOTO_REQUIRED');
const file = await db.orderFile.create({ data: { orderId: o.id, kind: 'PHOTO_REPORT', storageKey: `t/${o.id}.jpg`, mime: 'image/jpeg', size: 1, uploadedBy: 'PROVIDER' } });
await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true, fileId: file.id });
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' });
const done = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(done.status).toBe('DONE');
expect(done.completedAt).not.toBeNull();
const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
});
it('чужой исполнитель не может двигать заказ', async () => {
const { ctx } = makeCtx(NOW);
const o = await assigned(ctx, 51);
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.ani.id, action: 'depart' }))).toBe('NOT_FOUND');
});
it('бэкенд отклоняет недопустимый переход напрямую', async () => {
const { ctx } = makeCtx(NOW);
const o = await assigned(ctx, 52);
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DRIVER_WAITING', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
});
it('перенос: если исполнитель занят в новое время — заказ возвращается в «Новые»', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await assigned(ctx, 53);
// Мария уже занята в 15:00 другим заказом
const other = await newOrder(ctx, await onboardedClient(ctx, 54), slotAt(2, 15));
const f = await offerOrder(ctx, { orderId: other.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
const client = (await db.order.findUniqueOrThrow({ where: { id: o.id } })).clientId;
await rescheduleByClient(ctx, { orderId: o.id, clientId: client, scheduledAt: slotAt(2, 15) });
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'NEW', providerId: null });
expect(events).toContainEqual(expect.objectContaining({ type: 'order.unassigned', orderId: o.id }));
});
});
describe('оплата и напоминания', () => {
it('оплата вручную: начисление агентству только с оплаченных и в срок закрепления', async () => {
const { ctx } = makeCtx(NOW);
const agencyClient = await onboardedClient(ctx, 60, encodeStartPayload({ agency: 'DOMIK' }));
const o = await newOrder(ctx, agencyClient);
expect(await db.agencyCommission.count()).toBe(0);
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } });
const comm = await db.agencyCommission.findUniqueOrThrow({ where: { orderId: o.id } });
expect(comm).toMatchObject({ pct: 10, amount: Math.floor(o.totalPrice * 0.1), period: '2026-10' });
expect(await code(markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } }))).toBe('NOT_PAID_STATE');
const organic = await onboardedClient(ctx, 61);
const o2 = await newOrder(ctx, organic, slotAt(2, 13));
await markPaid(ctx, { orderId: o2.id, by: { type: 'ADMIN', id: 'admin' } });
expect(await db.agencyCommission.count()).toBe(1);
});
it('напоминания за 24 ч и 1 ч отправляются по одному разу', async () => {
const { ctx, events, clock } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 62), slotAt(2, 11));
clock.now = new Date(o.scheduledAt.getTime() - 23 * 3600_000);
expect(await dueReminders(ctx)).toBe(1);
expect(await dueReminders(ctx)).toBe(0);
clock.now = new Date(o.scheduledAt.getTime() - 50 * 60_000);
expect(await dueReminders(ctx)).toBe(1);
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { config } from 'dotenv';
import pg from 'pg';
/** Отдельная БД barev_test рядом с основной; схема накатывается миграциями */
export default async function setup() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
const base = new URL(process.env.DATABASE_URL!);
const testUrl = new URL(base);
testUrl.pathname = '/barev_test';
const admin = new pg.Client({ connectionString: base.toString() });
await admin.connect();
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
await admin.end();
execSync('npx prisma migrate deploy', {
cwd: path.resolve(import.meta.dirname, '../../db'),
env: { ...process.env, DATABASE_URL: testUrl.toString() },
stdio: 'pipe',
});
process.env.TEST_DATABASE_URL = testUrl.toString();
}
+87
View File
@@ -0,0 +1,87 @@
import path from 'node:path';
import { config } from 'dotenv';
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, fromYerevan, toYerevan, YEREVAN_DISTRICTS } from '@barev/core';
import { createDb, type Db } from '@barev/db';
import type { Ctx, DomainEvent } from '../src/context.ts';
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
function testUrl() {
const u = new URL(process.env.DATABASE_URL!);
u.pathname = '/barev_test';
return u.toString();
}
export const db: Db = createDb(testUrl());
export function makeCtx(now: Date) {
const events: DomainEvent[] = [];
const clock = { now };
const ctx: Ctx = { db, notify: { emit: (e) => void events.push(e) }, now: () => clock.now };
return { ctx, events, clock };
}
export async function wipe() {
const tables = await db.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
await db.$executeRawUnsafe(`TRUNCATE ${tables.map((t) => `"public"."${t.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
}
/** Понедельник 2026-10-19, 10:00 по Еревану — «сейчас» в тестах */
export const NOW = fromYerevan(2026, 10, 19, 10 * 60);
/** Слот послезавтра (ср) в hh:00 — вне 24-часового порога */
export const slotAt = (dayOffset: number, hour: number) => {
const p = toYerevan(new Date(NOW.getTime() + dayOffset * 24 * 3600_000));
return fromYerevan(p.year, p.month, p.day, hour * 60);
};
let tgSeq = 1000;
export async function fixtures() {
await wipe();
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
const c = CATALOG.find((s) => s.code === 'cleaning')!;
const cleaning = await db.service.create({
data: {
code: c.code,
categoryId: home.id,
flow: c.flow,
providerRole: c.providerRole,
pricing: c.pricing,
paramsSchema: c.paramsSchema,
slotConfig: c.slotConfig,
lateCancelFeePct: 30,
checklist: { create: c.checklist.map((x, i) => ({ code: x.code, requiresPhoto: !!x.requiresPhoto, sortOrder: i })) },
},
});
const agency = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik', commissionPct: 10 } });
await db.agency.create({ data: { code: 'CLOSED', name: 'Closed', active: false } });
const schedule = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
const mkProvider = async (name: string, languages: ('ru' | 'en' | 'hy')[], companyId?: string) => {
const user = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: name } });
return db.provider.create({
data: {
name,
userId: user.id,
role: 'CLEANER',
languages,
companyId,
inviteCode: `INV-${name}`,
onShift: true,
districts: { create: [{ districtId: kentron.id }] },
schedule: { create: schedule },
},
});
};
const maria = await mkProvider('Maria', ['ru', 'en']);
const ani = await mkProvider('Ani', ['hy']);
const company = await db.providerCompany.create({ data: { name: 'Chisto', roles: ['CLEANER'], inviteCode: 'CO-1', rateValue: 65 } });
const lusine = await mkProvider('Lusine', ['ru'], company.id);
const dispatcher = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: 'Karen' } });
await db.companyDispatcher.create({ data: { companyId: company.id, userId: dispatcher.id } });
return { kentron, cleaning, agency, maria, ani, company, lusine, dispatcher };
}
export const tgUser = (id: number, lang = 'ru') => ({ id, first_name: `User${id}`, language_code: lang });
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['./test/global-setup.ts'],
// тесты делят одну БД — запускаем последовательно
fileParallelism: false,
testTimeout: 20_000,
hookTimeout: 60_000,
},
});
+2679
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
packages:
- apps/*
- packages/*
allowBuilds:
prisma: true
"@prisma/engines": true
esbuild: true
"@parcel/watcher": true
sharp: true
"@swc/core": true
unrs-resolver: true
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] },
"typecheck": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}