From 4d331e4c0515ce674f80d68505e41d74a40d291d Mon Sep 17 00:00:00 2001 From: Arxip222 Date: Sun, 4 Oct 2026 01:13:56 +0300 Subject: [PATCH 1/9] docs(rag): describe RAG approach for Minzdrav clinical guidelines Structure-aware units with audience/evidence metadata, manual review, hybrid BM25 search, citation-checked answers and an eval set. Co-Authored-By: Claude Opus 5.5 --- docs/info/RAG_GUIDELINES.md | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/info/RAG_GUIDELINES.md diff --git a/docs/info/RAG_GUIDELINES.md b/docs/info/RAG_GUIDELINES.md new file mode 100644 index 0000000..a53e72b --- /dev/null +++ b/docs/info/RAG_GUIDELINES.md @@ -0,0 +1,142 @@ +# RAG по клиническим рекомендациям Минздрава + +> Статус: черновик подхода, 2026-10-04. Реализация — отдельной задачей (lane B, `app/llm/rag.py` по SPEC §структура). +> Пример исходника: КР «Посттравматическое стрессовое расстройство», ID 753, 2023 (Российское общество психиатров). + +## Принцип + +Файлов мало, глубина нужна максимальная, поэтому мы **не** делаем классический RAG «нарезка по N токенов → эмбеддинги → top-k». +Каждая КР один раз превращается в **размеченную и вычитанную базу знаний** из смысловых единиц. Поиск по ней почти тривиален, а +каждый ответ проверяется по цитате. + +## 1. Кому отвечает RAG + +КР написаны для врачей: дозы, препараты off-label (`#`), показания к госпитализации. Пересказывать это пациенту нельзя — +это противоречит нашим guardrails (`app/llm/med_qa.py`: о препаратах — только по инструкции ГРЛС и без доз). + +| Потребитель | Что можно отдавать из КР | +|---|---| +| Пациент (бот) | Только `Приложение В. Информация для пациента` и психообразование. Без доз и названий препаратов | +| Врач (дашборд) | Всё, с цитатой, уровнем убедительности (УУР) и достоверности доказательств (УДД) | +| Команда (design-time) | Обоснование порогов в `config/` (TSQ ≥ 6 — Прил. Г1, градации шкалы Бека — Г10): ссылки на источник, а не runtime-RAG | + +У каждой единицы есть поле `audience`. Фильтр по нему применяется **до** поиска, а не через промпт LLM. + +## 2. Разбивка на смысловые единицы + +Структура КР Минздрава стабильна, используем её как схему. + +| `type` | Пример из КР ПТСР | Как хранить | +|---|---|---| +| `recommendation` | «Пациентам с ПТСР … рекомендуется начинать терапию с СИОЗС…» | Тезис + УУР/УДД + комментарий + номера источников | +| `definition` / `criteria` | Критерии МКБ-10 А–Д, кластеры МКБ-11 | По одному критерию на единицу | +| `dose_row` | Таблица «Рекомендуемые дозы» (Прил. А3) | Строка: препарат, диапазон, `off_label`, `zhnvlp` (**) | +| `scale` | TSQ, CAPS, IES-R, BHS, TOP-8 (Прил. Г1–Г11) | Пункты + ключ интерпретации как данные | +| `algorithm` | Прил. Б и Б1 (в PDF это картинки) | Переписать вручную в шаги и ветвления | +| `patient_info` | Прил. В | Абзацы с `audience: patient` | +| `quality_criterion` | «Критерии оценки качества медицинской помощи» | По строке таблицы | + +Пример единицы: + +```json +{ + "id": "kr753.3.2.rec01", + "doc": {"kr_id": 753, "title": "ПТСР", "approved": "2023", "revise_by": "2025"}, + "path": ["3. Лечение", "3.2. Психофармакотерапия", "Терапия первой линии"], + "type": "recommendation", + "text": "Пациентам с ПТСР в качестве препаратов первой линии ... рекомендуется начинать терапию с СИОЗС ...", + "comment": "После установления диагноза ... эффективность оценивается через 4–6 недель ...", + "uur": "A", + "udd": 1, + "refs": [51, 53, 302], + "drugs": ["пароксетин", "сертралин", "флуоксетин", "венлафаксин"], + "contains_dose": true, + "off_label": ["флуоксетин", "венлафаксин"], + "audience": ["doctor"], + "pages": [17, 18] +} +``` + +Комментарий хранится вместе с тезисом: по отдельности они теряют смысл, а основная глубина КР — именно в комментариях. + +## 3. Пайплайн подготовки: автоматика, затем вычитка + +Файлов мало, поэтому ручная вычитка реальна и даёт больше всего качества. Повторяем практику `docs/clinical/`: хеши +исходников, `review-status.json`. + +1. **Источник.** Первоисточник — cr.minzdrav.gov.ru, а не перевёрстки (пример выше — перевёрстка с disuria.ru). Храним sha256. +2. **Очистка.** Убрать колонтитулы («Страница N из 117», рекламу сайта), склеить переносы и разрывы страниц + (слипания вида «клиникопсихопатологических»), собрать таблицы, разорванные между страницами. +3. **Парсер по шаблону.** Абзац «- Пациентам…» до строки «Уровень убедительности рекомендаций X (уровень достоверности + доказательств N)» — одна единица `recommendation`. Следующий за ней блок «Комментарии:» — её поле `comment`. +4. **Список литературы не индексируем.** Это шум для поиска; номера источников остаются в `refs`. +5. **Ручная вычитка.** В первую очередь — единицы для пациента и всё, где есть числа. В КР ПТСР уже видны ошибки самого + источника, которые автоматика пропустит: + - в таблице критериев качества перепутаны колонки УДД и УУР; + - раздел 6.1 встречается дважды; + - опечатка «ценить суицидальный риск» вместо «оценивать». +6. **Словарь аббревиатур и синонимов** — из списка сокращений в начале каждой КР. Нужен для расширения запросов: + СИОЗС ↔ сертралин/пароксетин, ДПДГ ↔ EMDR, ТФ-КПТ и т.д. + +Раскладка по ADR-008: + +``` +data/kb/sources/ исходные PDF + sha256 +data/kb/units/*.jsonl вычитанные единицы (в git) +data/kb/review-status.json статус вычитки по единицам +data/eval/kb_questions.yaml эталонные вопросы +``` + +## 4. Поиск + +Корпус маленький, тяжёлая инфраструктура не нужна. + +- **Фильтр по метаданным до поиска:** `audience`, `doc`, `type`. Вопрос пациента о дозе — отказ без поиска. +- **Гибридный поиск:** + - BM25 с русской лемматизацией (pymorphy3) и расширением запроса по словарю аббревиатур; + - эмбеддинги — только GigaChat Embeddings или локальная модель (guardrails 4 и 9; для офлайн-демо — только локальная). +- **Small-to-big.** Найден тезис — в контекст LLM уходит тезис с комментарием и заголовками родительского раздела. При + нашем объёме допустимо отдавать раздел целиком. +- **Порог уверенности.** Лучшая единица ниже порога — ответ «В клинических рекомендациях ответа на этот вопрос нет» + (по аналогии с `fallback` в `config/texts/med_qa.yaml`). + +## 5. Генерация строго по источнику + +Переиспользуем механизмы `app/llm/med_qa.py`: + +- LLM получает пронумерованные единицы и обязана ссылаться на `id` в каждом утверждении. +- **Детерминированная проверка после генерации:** + - каждое число в ответе встречается в процитированных единицах; + - доля «новых» слов ограничена (`MAX_NOVEL_STEM_SHARE`). + + Не прошло — вместо пересказа отдаём саму цитату. +- Ссылка на источник («КР ПТСР 2023, разд. 3.2, УУР A») и фраза «решение принимает врач» добавляются кодом, не через LLM. +- До поиска действуют общие guardrails: проверка безопасности (`acute` → фиксированный кризисный скрипт), затем `scrub()`, + затем вызов модели только через `app/llm/gateway.py`. + +## 6. Оценка качества + +Эталонный набор в `data/eval/` на 50–100 вопросов с ожидаемыми `id` единиц. Вопросы трёх видов: + +- прямые; +- перефразированные; +- ловушки, на которые в КР ответа нет. + +Метрики: + +- recall@k по `id` (есть ли нужные единицы среди первых k найденных); +- доля ответов, прошедших проверку по цитате; +- доля правильных отказов на ловушках. + +Оценка запускается обычным pytest-тестом, чтобы правки разметки не ломали поиск незаметно. + +## Риски и ограничения + +- **Актуальность.** КР ПТСР указывает «пересмотр не позднее 2025». Перед использованием проверить в рубрикаторе Минздрава + наличие новой редакции. `approved` / `revise_by` храним в метаданных и показываем в ответе. +- **Объём к дедлайну (05.10.2026, 13:00).** Реалистичный MVP: + - 2–3 КР (ПТСР, депрессия), размеченные полуавтоматически и вычитанные; + - BM25 + фильтр по `audience`; + - строгие цитаты. + + Эмбеддинги и реранкер — по остаточному принципу: глубину даёт качество разметки, а не векторная база. -- 2.54.0 From b8a93ce9d4ec2faeeba48b464bb66b8bab467f60 Mon Sep 17 00:00:00 2001 From: Arxip222 Date: Sun, 4 Oct 2026 01:36:58 +0300 Subject: [PATCH 2/9] =?UTF-8?q?feat(web):=20patient=20web=20app=20with=20?= =?UTF-8?q?=D0=95=D0=9C=D0=98=D0=90=D0=A1=20(demo)=20and=20anonymous=20sig?= =?UTF-8?q?n-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second channel to the same dialog engine as the Telegram bot: consent, onboarding, scales and check-ins come from app/dialog; the bot is unchanged. - API: /auth/emias (doctor's one-time connect code via EmiasMock), /auth/emias/demo (synthetic personas), /auth/anonymous (random token, nothing stored in the identity DB), /crisis/resources from crisis.yaml - web/: React + TS + Vite, mobile-first, safe areas, hash routing, tokens and texts isolated for a Capacitor mobile build Co-Authored-By: Claude Opus 5.5 --- app/api/fixtures.py | 6 + app/api/patient_web.py | 93 ++ app/api/routes.py | 8 +- app/integrations/emias_mock.py | 35 + tests/test_api_patient_web.py | 86 ++ web/.gitignore | 3 + web/README.md | 43 + web/index.html | 15 + web/package-lock.json | 1802 +++++++++++++++++++++++++++ web/package.json | 25 + web/public/favicon.svg | 1 + web/src/api/client.ts | 42 + web/src/api/types.ts | 41 + web/src/components/AppShell.tsx | 64 + web/src/components/HelpSheet.tsx | 69 + web/src/components/Icon.tsx | 78 ++ web/src/content/ru.ts | 103 ++ web/src/main.tsx | 39 + web/src/screens/AnonymousSignIn.tsx | 61 + web/src/screens/Chat.tsx | 267 ++++ web/src/screens/EmiasSignIn.tsx | 137 ++ web/src/screens/Welcome.tsx | 145 +++ web/src/session/SessionContext.tsx | 38 + web/src/session/store.ts | 74 ++ web/src/theme/global.css | 1306 +++++++++++++++++++ web/src/theme/tokens.css | 90 ++ web/tsconfig.json | 18 + web/vite.config.ts | 13 + 28 files changed, 4697 insertions(+), 5 deletions(-) create mode 100644 app/api/patient_web.py create mode 100644 app/integrations/emias_mock.py create mode 100644 tests/test_api_patient_web.py create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/components/AppShell.tsx create mode 100644 web/src/components/HelpSheet.tsx create mode 100644 web/src/components/Icon.tsx create mode 100644 web/src/content/ru.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/screens/AnonymousSignIn.tsx create mode 100644 web/src/screens/Chat.tsx create mode 100644 web/src/screens/EmiasSignIn.tsx create mode 100644 web/src/screens/Welcome.tsx create mode 100644 web/src/session/SessionContext.tsx create mode 100644 web/src/session/store.ts create mode 100644 web/src/theme/global.css create mode 100644 web/src/theme/tokens.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/app/api/fixtures.py b/app/api/fixtures.py index 79ea55a..9be6541 100644 --- a/app/api/fixtures.py +++ b/app/api/fixtures.py @@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo from app.api.schemas import ( AlertOut, + DemoPersona, DiscontinuationRisk, DoseOut, PatientCard, @@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow") TODAY = date(2026, 10, 3) DOCTOR_ID = "doc-smirnova" +# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app. +DEMO_PERSONAS: list[DemoPersona] = [ + DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"), +] + def _risk(score: int, factors: list[str]) -> DiscontinuationRisk: level = "high" if score >= 60 else "medium" if score >= 30 else "low" diff --git a/app/api/patient_web.py b/app/api/patient_web.py new file mode 100644 index 0000000..129f975 --- /dev/null +++ b/app/api/patient_web.py @@ -0,0 +1,93 @@ +"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`). + +The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...` +exactly like the web console, and consent, onboarding, scales and check-ins come from the engine. + +Two ways in: +- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The + patient is linked to the attending doctor, who sees the data on the dashboard. +- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine + supports tokens without an identity record (consent is kept in the dialog state), so no personal data is + stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor. + +The token returned here is the session credential of the prototype. Production needs short-lived sessions +issued by the server instead of handing out the pseudonymous token itself. +""" + +import logging +import secrets +from typing import Literal + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field + +from app.api import fixtures +from app.core.safety import CrisisResource, crisis_reply +from app.db import repo +from app.db.identity import TOKEN_LEN +from app.db.session import get_clinical_session +from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock + +log = logging.getLogger(__name__) + +router = APIRouter(tags=["patient-web"]) +_emias = EmiasMock() + + +class AuthOut(BaseModel): + token: str + mode: Literal["emias", "anonymous"] + display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design + + +class EmiasCodeIn(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + code: str = Field(min_length=1, max_length=CODE_MAX_LEN) + + +class EmiasDemoIn(BaseModel): + model_config = ConfigDict(extra="forbid") + persona_token: str = Field(min_length=1, max_length=64) + + +async def _attach_doctor(token: str, doctor_id: str) -> None: + async with get_clinical_session() as s: + if await repo.get_profile(s, token) is None: + await repo.upsert_profile(s, token=token, doctor_id=doctor_id) + + +@router.post("/auth/emias", response_model=AuthOut) +async def auth_emias(body: EmiasCodeIn) -> AuthOut: + """Sign-in through ЕМИАС (demo: one-time connect code from the doctor).""" + person = await _emias.verify(body.code) + if person is None: + log.info("web auth: emias code rejected") + raise HTTPException(401, "code not accepted") + await _attach_doctor(person.token, person.doctor_id) + log.info("web auth: emias tok=%s", person.token[:6]) + return AuthOut(token=person.token, mode="emias") + + +@router.post("/auth/emias/demo", response_model=AuthOut) +async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut: + """Demo ЕМИАС account: signs in as one of the synthetic personas (`/demo/personas`).""" + persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None) + if persona is None: + raise HTTPException(404, "persona not found") + await _attach_doctor(persona.token, fixtures.DOCTOR_ID) + log.info("web auth: emias demo tok=%s", persona.token[:6]) + return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name) + + +@router.post("/auth/anonymous", response_model=AuthOut) +async def auth_anonymous() -> AuthOut: + """Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere.""" + token = secrets.token_hex(TOKEN_LEN // 2) + log.info("web auth: anonymous tok=%s", token[:6]) + return AuthOut(token=token, mode="anonymous") + + +@router.get("/crisis/resources", response_model=list[CrisisResource]) +async def crisis_resources() -> list[CrisisResource]: + """Help lines for the always-visible «help now» button — the same list as the fixed crisis script.""" + return crisis_reply([], "patient").resources diff --git a/app/api/routes.py b/app/api/routes.py index 3c6d4e2..4f91337 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f from fastapi import APIRouter, HTTPException from app.api import fixtures +from app.api.patient_web import router as patient_web_router from app.api.questionnaire import router as questionnaire_router from app.api.schemas import ( AckIn, @@ -29,6 +30,7 @@ from app.scheduler import jobs as scheduler router = APIRouter(prefix="/api/v1") router.include_router(questionnaire_router) +router.include_router(patient_web_router) # ------------------------------------------------------------------ doctor dashboard @@ -114,8 +116,4 @@ async def demo_reset() -> dict[str, str]: @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) async def demo_personas() -> list[DemoPersona]: - return [ - DemoPersona( - token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl" - ) - ] + return fixtures.DEMO_PERSONAS diff --git a/app/integrations/emias_mock.py b/app/integrations/emias_mock.py new file mode 100644 index 0000000..c29fcbf --- /dev/null +++ b/app/integrations/emias_mock.py @@ -0,0 +1,35 @@ +"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code). + +Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect +code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The +class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel. + +It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice +(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the +production provider for all channels (#14). + +This module may use the identity DB: it is the registration boundary (guardrail #5). +""" + +from app.core import clock +from app.db import identity_repo +from app.db.identity import ConnectCode +from app.db.session import get_identity_session +from app.integrations.base import VerifiedPerson + +CODE_MAX_LEN = 32 + + +class EmiasMock: + async def verify(self, auth_code: str) -> VerifiedPerson | None: + code = auth_code.strip().upper() + if not code or len(code) > CODE_MAX_LEN: + return None + async with get_identity_session() as s: + row = await s.get(ConnectCode, code) + if row is None: + return None + token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now()) + if token is None: + return None + return VerifiedPerson(token=token, doctor_id=row.doctor_id) diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py new file mode 100644 index 0000000..e8da9eb --- /dev/null +++ b/tests/test_api_patient_web.py @@ -0,0 +1,86 @@ +"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine.""" + +from collections.abc import AsyncIterator + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.api import fixtures +from app.core import clock +from app.db import identity_repo, repo, session +from app.db.bootstrap import init_db +from app.main import app + +MEM = "sqlite+aiosqlite:///:memory:" +TOKEN = "b" * 32 + + +@pytest.fixture +async def client() -> AsyncIterator[AsyncClient]: + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + await session.configure() + + +async def _patient_with_code(code: str) -> None: + async with session.get_identity_session() as s: + await identity_repo.create_patient( + s, + token=TOKEN, + display_name="Синтетический П.", + snils="000-000-000 00", + clinic_id="clinic-1", + doctor_id="doc-1", + now=clock.now(), + ) + await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now()) + + +async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient) -> None: + first = (await client.post("/api/v1/auth/anonymous")).json() + second = (await client.post("/api/v1/auth/anonymous")).json() + assert first["mode"] == "anonymous" and first["display_name"] is None + assert len(first["token"]) == 32 and first["token"] != second["token"] + async with session.get_identity_session() as s: + assert await identity_repo.get_patient(s, first["token"]) is None + async with session.get_clinical_session() as s: + assert await repo.get_profile(s, first["token"]) is None + + +async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous")).json()["token"] + replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"] + buttons = [b["data"] for r in replies for row in r["buttons"] for b in row] + assert "consent:yes" in buttons and "consent:no" in buttons + + +async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None: + await _patient_with_code("DEMO-0042") + ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "}) + assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None} + async with session.get_clinical_session() as s: + assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1" + again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"}) + assert again.status_code == 401 + + +async def test_emias_rejects_unknown_code(client: AsyncClient) -> None: + assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401 + assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422 + + +async def test_emias_demo_persona(client: AsyncClient) -> None: + persona = fixtures.DEMO_PERSONAS[0] + res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token}) + assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name} + async with session.get_clinical_session() as s: + assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID + missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"}) + assert missing.status_code == 404 + + +async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: + phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()] + assert "112" in phones diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..db2e6a3 --- /dev/null +++ b/web/README.md @@ -0,0 +1,43 @@ +# Веб-приложение пациента + +Ещё один канал к тому же движку диалога, что и Telegram-бот (ADR-004): согласие, онбординг, шкалы, чек-ины и +кризисный скрипт приходят из `app/dialog` через `/api/v1/dialog/{token}/...`. Бот продолжает работать параллельно. + +## Запуск + +```bash +make up # или: uv run uvicorn app.main:app --port 8000 +cd web && npm install +npm run dev # http://localhost:5173 — /api проксируется на :8000 +``` + +Интерфейс открывается на **:5173**; на :8000 работает только API (`/docs`). + +## Вход + +| Способ | Что происходит | API | +|---|---|---| +| ЕМИАС (демо) | одноразовый код от врача → токен пациента, привязка к врачу; или синтетический демо-пациент | `POST /auth/emias`, `POST /auth/emias/demo` | +| Анонимно | случайный токен, в identity-БД ничего не пишется, врач не привязан | `POST /auth/anonymous` | + +Реальная авторизация ЕМИАС/ЕСИА подключается как `IdentityProvider` (`app/integrations/emias_mock.py`) без +изменений во фронтенде. + +## Структура (под перенос в мобильное приложение) + +``` +src/theme/tokens.css цвета, шрифт, отступы — единственный источник; три цвета = три направления сервиса +src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок) +src/api/ типы-зеркала контрактов бэкенда и клиент +src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain +src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat +``` + +Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается +Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API). + +## Известные ограничения прототипа + +- Токен пациента используется как учётные данные сессии; в проде — короткоживущие серверные сессии. +- Подбор кодов не ограничен по частоте. +- В демо нет сида кодов подключения: для показа используйте демо-пациента. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..379a015 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Сопровождение пациента + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3dfda63 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1802 @@ +{ + "name": "ptsr-patient-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ptsr-patient-web", + "version": "0.1.0", + "dependencies": { + "@fontsource/golos-text": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource/golos-text": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/golos-text/-/golos-text-5.3.0.tgz", + "integrity": "sha512-R+gM4qOZSrge4TtEGps0zTpU8X4e7e32L+3yy0J/2LnVB/IERlidEpXPWSZVGzTaL5QnPRVmoebRXSuVtjUIVg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", + "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", + "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", + "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", + "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", + "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", + "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", + "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", + "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", + "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", + "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", + "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", + "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", + "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", + "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", + "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", + "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", + "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", + "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", + "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", + "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", + "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", + "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", + "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", + "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", + "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", + "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.0", + "@rollup/rollup-android-arm64": "4.64.0", + "@rollup/rollup-darwin-arm64": "4.64.0", + "@rollup/rollup-darwin-x64": "4.64.0", + "@rollup/rollup-freebsd-arm64": "4.64.0", + "@rollup/rollup-freebsd-x64": "4.64.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", + "@rollup/rollup-linux-arm-musleabihf": "4.64.0", + "@rollup/rollup-linux-arm64-gnu": "4.64.0", + "@rollup/rollup-linux-arm64-musl": "4.64.0", + "@rollup/rollup-linux-loong64-gnu": "4.64.0", + "@rollup/rollup-linux-loong64-musl": "4.64.0", + "@rollup/rollup-linux-ppc64-gnu": "4.64.0", + "@rollup/rollup-linux-ppc64-musl": "4.64.0", + "@rollup/rollup-linux-riscv64-gnu": "4.64.0", + "@rollup/rollup-linux-riscv64-musl": "4.64.0", + "@rollup/rollup-linux-s390x-gnu": "4.64.0", + "@rollup/rollup-linux-x64-gnu": "4.64.0", + "@rollup/rollup-linux-x64-musl": "4.64.0", + "@rollup/rollup-openbsd-x64": "4.64.0", + "@rollup/rollup-openharmony-arm64": "4.64.0", + "@rollup/rollup-win32-arm64-msvc": "4.64.0", + "@rollup/rollup-win32-ia32-msvc": "4.64.0", + "@rollup/rollup-win32-x64-gnu": "4.64.0", + "@rollup/rollup-win32-x64-msvc": "4.64.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..66b24ad --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "ptsr-patient-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "@fontsource/golos-text": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.3", + "vite": "^5.4.10" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..7830f3e --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..c512b5c --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,42 @@ +import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types"; + +// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build. +const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1"; + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function request(path: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }); + } catch { + throw new ApiError(0, "network"); + } + if (!res.ok) throw new ApiError(res.status, res.statusText); + return (await res.json()) as T; +} + +const post = (path: string, body?: unknown) => + request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }); + +export const api = { + signInEmias: (code: string) => post("/auth/emias", { code }), + signInEmiasDemo: (personaToken: string) => post("/auth/emias/demo", { persona_token: personaToken }), + signInAnonymous: () => post("/auth/anonymous"), + demoPersonas: () => request("/demo/personas"), + crisisResources: () => request("/crisis/resources"), + sendEvent: async (token: string, event: DialogEvent) => + (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, + outbox: async (token: string) => + (await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies, +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..1e40513 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,41 @@ +// Mirrors of the backend contracts (app/dialog/contracts.py, app/api/schemas.py, app/api/patient_web.py). +// Keep in sync by hand: the frozen contracts change only through a [contract] PR. + +export interface DialogButton { + label: string; + data: string; +} + +export type ReplyKind = "normal" | "question" | "crisis" | "safety_plan" | "info"; + +export interface Reply { + text: string; + buttons: DialogButton[][]; + kind: ReplyKind; +} + +export type DialogEvent = + | { type: "start"; params?: Record } + | { type: "text"; text: string } + | { type: "button"; button: string }; + +export type AuthMode = "emias" | "anonymous"; + +export interface AuthResult { + token: string; + mode: AuthMode; + display_name: string | null; +} + +export interface CrisisResource { + name: string; + phone: string; + note: string; +} + +export interface DemoPersona { + token: string; + display_name: string; + therapy_day: number; + scenario: string | null; +} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..42dbd69 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,64 @@ +import { useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import { ru } from "../content/ru"; +import { HelpSheet } from "./HelpSheet"; +import { Icon, LogoMark } from "./Icon"; + +export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) { + const [helpOpen, setHelpOpen] = useState(false); + + return ( +
+
+
+ + + + {ru.product.name} + {ru.product.tagline} + + + +
+
+ + {children} + + {footer && ( +
+
+

+ + {ru.footer.law} +

+

{ru.footer.prototype}

+
+
+ )} + + setHelpOpen(false)} /> +
+ ); +} + +export function BackLink({ to, children }: { to: string; children: ReactNode }) { + return ( + + + {children} + + ); +} + +export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} diff --git a/web/src/components/HelpSheet.tsx b/web/src/components/HelpSheet.tsx new file mode 100644 index 0000000..b112a9f --- /dev/null +++ b/web/src/components/HelpSheet.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from "react"; + +import { api } from "../api/client"; +import type { CrisisResource } from "../api/types"; +import { ru } from "../content/ru"; +import { Icon } from "./Icon"; + +// 112 is shown even when the API is unreachable: the help sheet must never be empty. +const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }]; + +const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; + +export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) { + const ref = useRef(null); + const [resources, setResources] = useState(FALLBACK); + + useEffect(() => { + api + .crisisResources() + .then((r) => r.length && setResources(r)) + .catch(() => undefined); + }, []); + + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + }, [open]); + + return ( + e.target === ref.current && onClose()} + > +
+
+

+ {ru.help.title} +

+ +
+

{ru.help.lead}

+ +
+
+ ); +} diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx new file mode 100644 index 0000000..d9279ec --- /dev/null +++ b/web/src/components/Icon.tsx @@ -0,0 +1,78 @@ +// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers. + +const paths = { + check: , + minus: , + phone: ( + + ), + arrowLeft: , + arrowRight: , + send: , + info: ( + <> + + + + ), + alert: ( + <> + + + + ), + lock: ( + <> + + + + ), + pill: ( + <> + + + + ), + pulse: , + doctor: ( + <> + + + + + ), + close: , + logout: , +} as const; + +export type IconName = keyof typeof paths; + +export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) { + return ( + + {paths[name]} + + ); +} + +export function LogoMark({ size = 32 }: { size?: number }) { + return ( + + ); +} diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts new file mode 100644 index 0000000..e8b851f --- /dev/null +++ b/web/src/content/ru.ts @@ -0,0 +1,103 @@ +/* + * All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/ + * for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here. + */ +export const ru = { + product: { + name: "Сопровождение пациента", + tagline: "между визитами к врачу", + prototype: "Прототип · синтетические данные", + }, + help: { + button: "Нужна помощь", + title: "Если вам сейчас очень тяжело", + lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:", + call: "Позвонить", + close: "Закрыть", + fallbackName: "Экстренные службы", + fallbackNote: "если есть угроза жизни прямо сейчас", + }, + welcome: { + title: "Лечение продолжается и между приёмами", + lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.", + day: { + caption: "Обычный день с сервисом", + meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" }, + mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" }, + doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" }, + }, + choose: "Как вы хотите войти?", + emias: { + title: "Через ЕМИАС", + badge: "Рекомендуем", + facts: [ + { ok: true, text: "Назначения врача подгружаются автоматически" }, + { ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" }, + { ok: true, text: "История доступна с любого устройства" }, + ], + action: "Войти через ЕМИАС", + }, + anonymous: { + title: "Анонимно", + facts: [ + { ok: true, text: "Не нужны имя, полис и телефон" }, + { ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" }, + { ok: false, text: "Врач не видит ваши ответы и не получает сигналов" }, + { ok: false, text: "История доступна только на этом устройстве" }, + ], + action: "Продолжить анонимно", + }, + disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.", + }, + emias: { + back: "Способы входа", + title: "Вход через ЕМИАС", + demoNotice: + "Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.", + codeLabel: "Код подключения от врача", + codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.", + codePlaceholder: "Код из памятки", + submit: "Войти", + submitting: "Проверяем код…", + codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.", + networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.", + or: "или", + demoTitle: "Демо-пациенты", + demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.", + demoDay: (day: number) => `${day}-й день терапии`, + demoAction: "Войти", + demoEmpty: "Демо-пациенты недоступны.", + }, + anonymous: { + back: "Способы входа", + title: "Анонимный вход", + lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.", + points: [ + "Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.", + "Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.", + "Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.", + ], + warning: + "История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.", + submit: "Начать анонимно", + submitting: "Создаём анонимный профиль…", + }, + chat: { + modeEmias: "ЕМИАС", + modeAnonymous: "Анонимно", + logout: "Выйти", + logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.", + inputLabel: "Сообщение", + inputPlaceholder: "Напишите сообщение", + send: "Отправить", + loading: "Подключаемся…", + sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.", + you: "Вы", + assistant: "Помощник", + crisisLabel: "Важно", + }, + footer: { + law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).", + prototype: "Прототип для хакатона MedITron. Используются только синтетические данные.", + }, +} as const; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..a1563b2 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,39 @@ +import "@fontsource/golos-text/400.css"; +import "@fontsource/golos-text/500.css"; +import "@fontsource/golos-text/600.css"; +import "./theme/tokens.css"; +import "./theme/global.css"; + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; + +import { AnonymousSignIn } from "./screens/AnonymousSignIn"; +import { Chat } from "./screens/Chat"; +import { EmiasSignIn } from "./screens/EmiasSignIn"; +import { Welcome } from "./screens/Welcome"; +import { SessionProvider, useSession } from "./session/SessionContext"; + +// Hash routing works the same on a static host, file:// and inside the Capacitor WebView. +function AppRoutes() { + const { session } = useSession(); + return ( + + : } /> + } /> + } /> + } /> + } /> + + ); +} + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx new file mode 100644 index 0000000..364ce8b --- /dev/null +++ b/web/src/screens/AnonymousSignIn.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "../api/client"; +import { AppShell, BackLink, Notice } from "../components/AppShell"; +import { Icon } from "../components/Icon"; +import { ru } from "../content/ru"; +import { useSession } from "../session/SessionContext"; + +export function AnonymousSignIn() { + const t = ru.anonymous; + const navigate = useNavigate(); + const { signIn } = useSession(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + + const start = async () => { + setBusy(true); + setError(false); + try { + signIn(await api.signInAnonymous()); + navigate("/chat", { replace: true }); + } catch { + setError(true); + setBusy(false); + } + }; + + return ( + +
+
+ {t.back} +
+

+ {t.title} +

+

{t.lead}

+
    + {t.points.map((p) => ( +
  • + + {p} +
  • + ))} +
+ {t.warning} + {error && ( +

+ {ru.emias.networkError} +

+ )} + +
+
+
+
+ ); +} diff --git a/web/src/screens/Chat.tsx b/web/src/screens/Chat.tsx new file mode 100644 index 0000000..041c986 --- /dev/null +++ b/web/src/screens/Chat.tsx @@ -0,0 +1,267 @@ +import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "../api/client"; +import type { DialogButton, DialogEvent, Reply } from "../api/types"; +import { AppShell } from "../components/AppShell"; +import { Icon } from "../components/Icon"; +import { ru } from "../content/ru"; +import { useSession } from "../session/SessionContext"; +import { sessionStore, type ChatEntry } from "../session/store"; + +// The engine pushes reminders and check-ins to the outbox; the web channel polls it (Telegram gets them pushed). +const OUTBOX_POLL_MS = 6000; + +let seq = 0; +const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`; + +const assistantEntries = (replies: Reply[]): ChatEntry[] => + replies.map((reply) => ({ id: newId(), from: "assistant", reply, answered: false })); + +// Phone numbers in engine texts (crisis script, help lines) become tap-to-call links. +const PHONE_RE = /(\+7[\d\s()-]{9,}\d|8 \(\d{3}\) \d{3}|\b11[2]\b|\b10[23]\b)/g; + +function RichText({ text }: { text: string }) { + return ( + <> + {text.split("\n").map((line, i) => ( + + {i > 0 &&
} + {line.split(PHONE_RE).map((part, j) => + j % 2 === 1 ? ( + + {part} + + ) : ( + {part} + ), + )} +
+ ))} + + ); +} + +function AssistantMessage({ + entry, + disabled, + onPress, +}: { + entry: Extract; + disabled: boolean; + onPress: (button: DialogButton) => void; +}) { + const { reply } = entry; + const special = reply.kind === "crisis" || reply.kind === "safety_plan"; + return ( +
  • +
    + {special && ( +

    + + {ru.chat.crisisLabel} +

    + )} +

    + +

    +
    + {reply.buttons.length > 0 && !entry.answered && ( +
    + {reply.buttons.map((row, r) => ( +
    + {row.map((b) => ( + + ))} +
    + ))} +
    + )} +
  • + ); +} + +export function Chat() { + const t = ru.chat; + const navigate = useNavigate(); + const { session, signOut } = useSession(); + const token = session?.token ?? ""; + const [entries, setEntries] = useState(() => (token ? sessionStore.loadChat(token) : [])); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const [confirmLogout, setConfirmLogout] = useState(false); + const endRef = useRef(null); + const started = useRef(false); + + useEffect(() => { + if (token) sessionStore.saveChat(token, entries); + endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); + }, [entries, token]); + + // Any patient action closes the open buttons: the engine has moved on, old choices are stale. + const dispatch = useCallback( + async (event: DialogEvent, optimistic: ChatEntry[]) => { + setBusy(true); + setError(false); + let reopened = new Set(); + setEntries((prev) => { + reopened = new Set(prev.filter((e) => e.from === "assistant" && !e.answered).map((e) => e.id)); + return [...prev.map((e) => (reopened.has(e.id) ? { ...e, answered: true } : e)), ...optimistic]; + }); + try { + const replies = await api.sendEvent(token, event); + setEntries((prev) => [...prev, ...assistantEntries(replies)]); + return true; + } catch { + // Roll back the optimistic patient message and re-open the buttons. + const ids = new Set(optimistic.map((o) => o.id)); + setEntries((prev) => + prev.filter((e) => !ids.has(e.id)).map((e) => (reopened.has(e.id) ? { ...e, answered: false } : e)), + ); + setError(true); + return false; + } finally { + setBusy(false); + } + }, + [token], + ); + + // First visit: the engine opens the dialog (consent → onboarding) exactly as for /start in Telegram. + useEffect(() => { + if (!token || started.current) return; + started.current = true; + if (entries.length === 0) void dispatch({ type: "start" }, []); + }, [token, entries.length, dispatch]); + + useEffect(() => { + if (!token) return; + const timer = window.setInterval(() => { + api + .outbox(token) + .then((replies) => replies.length && setEntries((prev) => [...prev, ...assistantEntries(replies)])) + .catch(() => undefined); + }, OUTBOX_POLL_MS); + return () => window.clearInterval(timer); + }, [token]); + + useEffect(() => { + if (!session) navigate("/", { replace: true }); + }, [session, navigate]); + + if (!session) return null; + + const sendText = async (e?: FormEvent) => { + e?.preventDefault(); + const text = draft.trim(); + if (!text || busy) return; + setDraft(""); + const ok = await dispatch({ type: "text", text }, [{ id: newId(), from: "patient", text }]); + if (!ok) setDraft(text); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) void sendText(); + }; + + const press = (b: DialogButton) => + void dispatch({ type: "button", button: b.data }, [{ id: newId(), from: "patient", text: b.label }]); + + return ( + +
    +
    + + + {session.mode === "emias" ? t.modeEmias : t.modeAnonymous} + {session.displayName && · {session.displayName}} + + {confirmLogout ? ( + + {t.logoutConfirm} + + + + ) : ( + + )} +
    +
    + +
    +
      + {entries.map((entry) => + entry.from === "assistant" ? ( + + ) : ( +
    1. + {t.you}: +
      +

      {entry.text}

      +
      +
    2. + ), + )} + {busy && ( +
    3. +
      + +
      +
    4. + )} +
    +
    +
    + +
    + {error && ( +

    + {t.sendError} +

    + )} +
    + +