feat(web): patient web app with ЕМИАС (demo) and anonymous sign-in
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
+3
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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).
|
||||
|
||||
## Известные ограничения прототипа
|
||||
|
||||
- Токен пациента используется как учётные данные сессии; в проде — короткоживущие серверные сессии.
|
||||
- Подбор кодов не ограничен по частоте.
|
||||
- В демо нет сида кодов подключения: для показа используйте демо-пациента.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="description" content="Сопровождение пациентов между визитами к врачу" />
|
||||
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
||||
<title>Сопровождение пациента</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1802
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a4da2"/><path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 175 B |
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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 = <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
|
||||
export const api = {
|
||||
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
|
||||
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
|
||||
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
|
||||
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
|
||||
crisisResources: () => request<CrisisResource[]>("/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,
|
||||
};
|
||||
@@ -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<string, unknown> }
|
||||
| { 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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="topbar__inner">
|
||||
<Link to="/" className="brand" aria-label={ru.product.name}>
|
||||
<LogoMark />
|
||||
<span className="brand__text">
|
||||
<span className="brand__name">{ru.product.name}</span>
|
||||
<span className="brand__tagline">{ru.product.tagline}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}>
|
||||
<Icon name="phone" size={18} />
|
||||
<span>{ru.help.button}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
|
||||
{footer && (
|
||||
<footer className="footer">
|
||||
<div className="footer__inner">
|
||||
<p className="footer__line">
|
||||
<Icon name="lock" size={16} />
|
||||
<span>{ru.footer.law}</span>
|
||||
</p>
|
||||
<p className="footer__line footer__line--muted">{ru.footer.prototype}</p>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<HelpSheet open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackLink({ to, children }: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link to={to} className="back-link">
|
||||
<Icon name="arrowLeft" size={18} />
|
||||
<span>{children}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
|
||||
return (
|
||||
<div className={`notice notice--${tone}`} role="note">
|
||||
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDialogElement>(null);
|
||||
const [resources, setResources] = useState<CrisisResource[]>(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 (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="sheet"
|
||||
aria-labelledby="help-title"
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === ref.current && onClose()}
|
||||
>
|
||||
<div className="sheet__body">
|
||||
<div className="sheet__head">
|
||||
<h2 id="help-title" className="sheet__title">
|
||||
{ru.help.title}
|
||||
</h2>
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="sheet__lead">{ru.help.lead}</p>
|
||||
<ul className="hotlines">
|
||||
{resources.map((r) => (
|
||||
<li key={r.phone}>
|
||||
<a className="hotline" href={telHref(r.phone)}>
|
||||
<span className="hotline__icon">
|
||||
<Icon name="phone" size={22} />
|
||||
</span>
|
||||
<span className="hotline__text">
|
||||
<span className="hotline__phone">{r.phone}</span>
|
||||
<span className="hotline__name">{r.name}</span>
|
||||
{r.note && <span className="hotline__note">{r.note}</span>}
|
||||
</span>
|
||||
<span className="hotline__call">{ru.help.call}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -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: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
||||
minus: <path d="M6 12h12" />,
|
||||
phone: (
|
||||
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
||||
),
|
||||
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
|
||||
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />,
|
||||
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5m0-8.5v.5" />
|
||||
</>
|
||||
),
|
||||
alert: (
|
||||
<>
|
||||
<path d="M12 3.5l9.5 16.5h-19L12 3.5z" />
|
||||
<path d="M12 10v4.5m0 2.5v.5" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
|
||||
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
|
||||
</>
|
||||
),
|
||||
pill: (
|
||||
<>
|
||||
<rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
||||
<path d="M9.5 9.5l5 5" />
|
||||
</>
|
||||
),
|
||||
pulse: <path d="M3 12h4l2-5 4 10 2-5h6" />,
|
||||
doctor: (
|
||||
<>
|
||||
<circle cx="12" cy="7.5" r="3.5" />
|
||||
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
||||
<path d="M12 15.5v3m-1.5-1.5h3" />
|
||||
</>
|
||||
),
|
||||
close: <path d="M6 6l12 12M18 6L6 18" />,
|
||||
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof paths;
|
||||
|
||||
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
focusable="false"
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoMark({ size = 32 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
||||
<rect width="32" height="32" rx="6" fill="var(--c-primary)" />
|
||||
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={session ? <Navigate to="/chat" replace /> : <Welcome />} />
|
||||
<Route path="/emias" element={<EmiasSignIn />} />
|
||||
<Route path="/anonymous" element={<AnonymousSignIn />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<SessionProvider>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
</SessionProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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 (
|
||||
<AppShell>
|
||||
<main className="page" id="main">
|
||||
<div className="narrow">
|
||||
<BackLink to="/">{t.back}</BackLink>
|
||||
<section className="panel" aria-labelledby="anon-title">
|
||||
<h1 id="anon-title" className="h2">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="lead lead--sm">{t.lead}</p>
|
||||
<ul className="facts facts--spaced">
|
||||
{t.points.map((p) => (
|
||||
<li key={p} className="fact">
|
||||
<Icon name="check" size={18} />
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Notice tone="warning">{t.warning}</Notice>
|
||||
{error && (
|
||||
<p className="field__error" role="alert">
|
||||
{ru.emias.networkError}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line.split(PHONE_RE).map((part, j) =>
|
||||
j % 2 === 1 ? (
|
||||
<a key={j} href={`tel:${part.replace(/[^\d+]/g, "")}`} className="tel">
|
||||
{part}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={j}>{part}</Fragment>
|
||||
),
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
entry,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
entry: Extract<ChatEntry, { from: "assistant" }>;
|
||||
disabled: boolean;
|
||||
onPress: (button: DialogButton) => void;
|
||||
}) {
|
||||
const { reply } = entry;
|
||||
const special = reply.kind === "crisis" || reply.kind === "safety_plan";
|
||||
return (
|
||||
<li className={`msg msg--assistant msg--${reply.kind}`}>
|
||||
<div className="msg__bubble">
|
||||
{special && (
|
||||
<p className="msg__label">
|
||||
<Icon name="alert" size={16} />
|
||||
{ru.chat.crisisLabel}
|
||||
</p>
|
||||
)}
|
||||
<p className="msg__text">
|
||||
<RichText text={reply.text} />
|
||||
</p>
|
||||
</div>
|
||||
{reply.buttons.length > 0 && !entry.answered && (
|
||||
<div className="choices">
|
||||
{reply.buttons.map((row, r) => (
|
||||
<div key={r} className="choices__row">
|
||||
{row.map((b) => (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
className="choice"
|
||||
disabled={disabled}
|
||||
onClick={() => onPress(b)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const t = ru.chat;
|
||||
const navigate = useNavigate();
|
||||
const { session, signOut } = useSession();
|
||||
const token = session?.token ?? "";
|
||||
const [entries, setEntries] = useState<ChatEntry[]>(() => (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<HTMLDivElement>(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<string>();
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<AppShell footer={false}>
|
||||
<div className="chat-bar">
|
||||
<div className="chat-bar__inner">
|
||||
<span className={`mode mode--${session.mode}`}>
|
||||
<Icon name={session.mode === "emias" ? "doctor" : "lock"} size={16} />
|
||||
{session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
|
||||
{session.displayName && <span className="mode__name">· {session.displayName}</span>}
|
||||
</span>
|
||||
{confirmLogout ? (
|
||||
<span className="logout-confirm" role="group" aria-label={t.logout}>
|
||||
<span className="logout-confirm__text">{t.logoutConfirm}</span>
|
||||
<button type="button" className="btn btn--danger btn--sm" onClick={signOut}>
|
||||
{t.logout}
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label={ru.help.close} onClick={() => setConfirmLogout(false)}>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
|
||||
>
|
||||
<Icon name="logout" size={18} />
|
||||
{t.logout}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="chat" id="main">
|
||||
<ol className="messages" aria-live="polite" aria-label={t.assistant}>
|
||||
{entries.map((entry) =>
|
||||
entry.from === "assistant" ? (
|
||||
<AssistantMessage key={entry.id} entry={entry} disabled={busy} onPress={press} />
|
||||
) : (
|
||||
<li key={entry.id} className="msg msg--patient">
|
||||
<span className="visually-hidden">{t.you}: </span>
|
||||
<div className="msg__bubble">
|
||||
<p className="msg__text">{entry.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{busy && (
|
||||
<li className="msg msg--assistant msg--typing" aria-label={t.loading}>
|
||||
<div className="msg__bubble">
|
||||
<span className="dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
<div ref={endRef} />
|
||||
</main>
|
||||
|
||||
<form className="composer" onSubmit={sendText}>
|
||||
{error && (
|
||||
<p className="composer__error" role="alert">
|
||||
{t.sendError}
|
||||
</p>
|
||||
)}
|
||||
<div className="composer__row">
|
||||
<label htmlFor="msg" className="visually-hidden">
|
||||
{t.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
className="composer__input"
|
||||
rows={1}
|
||||
value={draft}
|
||||
placeholder={t.inputPlaceholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={busy || !draft.trim()} aria-label={t.send}>
|
||||
<Icon name="send" size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError, api } from "../api/client";
|
||||
import type { AuthResult, DemoPersona } from "../api/types";
|
||||
import { AppShell, BackLink, Notice } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
export function EmiasSignIn() {
|
||||
const t = ru.emias;
|
||||
const navigate = useNavigate();
|
||||
const { signIn } = useSession();
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [personas, setPersonas] = useState<DemoPersona[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.demoPersonas()
|
||||
.then(setPersonas)
|
||||
.catch(() => setPersonas([]));
|
||||
}, []);
|
||||
|
||||
const finish = (auth: AuthResult) => {
|
||||
signIn(auth);
|
||||
navigate("/chat", { replace: true });
|
||||
};
|
||||
|
||||
const fail = (e: unknown) => {
|
||||
setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!code.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
finish(await api.signInEmias(code.trim()));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const demo = async (persona: DemoPersona) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
finish(await api.signInEmiasDemo(persona.token));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="page" id="main">
|
||||
<div className="narrow">
|
||||
<BackLink to="/">{t.back}</BackLink>
|
||||
<section className="panel" aria-labelledby="emias-title">
|
||||
<h1 id="emias-title" className="h2">
|
||||
{t.title}
|
||||
</h1>
|
||||
<Notice>{t.demoNotice}</Notice>
|
||||
|
||||
<form className="form" onSubmit={submit} noValidate>
|
||||
<div className={error ? "field field--error" : "field"}>
|
||||
<label htmlFor="code" className="field__label">
|
||||
{t.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
className="input"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t.codePlaceholder}
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
maxLength={32}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? "code-error" : "code-hint"}
|
||||
/>
|
||||
{error ? (
|
||||
<p id="code-error" className="field__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p id="code-hint" className="field__hint">
|
||||
{t.codeHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn--primary btn--block" disabled={busy || !code.trim()}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="divider" role="separator">
|
||||
<span>{t.or}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="h3">{t.demoTitle}</h2>
|
||||
<p className="muted">{t.demoLead}</p>
|
||||
{personas === null ? null : personas.length === 0 ? (
|
||||
<p className="muted">{t.demoEmpty}</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{personas.map((p) => (
|
||||
<li key={p.token}>
|
||||
<button type="button" className="list-row" onClick={() => demo(p)} disabled={busy}>
|
||||
<span className="avatar" aria-hidden="true">
|
||||
{p.display_name.charAt(0)}
|
||||
</span>
|
||||
<span className="list-row__text">
|
||||
<span className="list-row__title">{p.display_name}</span>
|
||||
<span className="list-row__sub">{t.demoDay(p.therapy_day)}</span>
|
||||
</span>
|
||||
<span className="list-row__action">
|
||||
{t.demoAction}
|
||||
<Icon name="arrowRight" size={18} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
|
||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
||||
const WEEK = [38, 52, 46, 60, 58, 72, 80]; // illustrative trend, %
|
||||
|
||||
function Facts({ items }: { items: readonly { ok: boolean; text: string }[] }) {
|
||||
return (
|
||||
<ul className="facts">
|
||||
{items.map((f) => (
|
||||
<li key={f.text} className={f.ok ? "fact" : "fact fact--no"}>
|
||||
<Icon name={f.ok ? "check" : "minus"} size={18} />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Illustration of a day with the service (decorative: the same facts are stated in text).
|
||||
function DayPreview() {
|
||||
const d = ru.welcome.day;
|
||||
return (
|
||||
<figure className="day" aria-hidden="true">
|
||||
<figcaption className="day__caption">{d.caption}</figcaption>
|
||||
<ol className="day__list">
|
||||
<li className="day__item day__item--meds">
|
||||
<span className="day__time">{d.meds.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="pill" size={16} />
|
||||
{d.meds.track}
|
||||
</span>
|
||||
<div className="day__row">
|
||||
<span className="day__text">{d.meds.text}</span>
|
||||
<span className="day__done">
|
||||
<Icon name="check" size={16} />
|
||||
{d.meds.done}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="day__item day__item--mood">
|
||||
<span className="day__time">{d.mood.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="pulse" size={16} />
|
||||
{d.mood.track}
|
||||
</span>
|
||||
<span className="day__text">{d.mood.text}</span>
|
||||
<div className="scale">
|
||||
{SCALE.map((n) => (
|
||||
<span key={n} className={`scale__dot scale__dot--${n}${n === 4 ? " is-picked" : ""}`}>
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="scale__ends">
|
||||
<span>{d.mood.low}</span>
|
||||
<span>{d.mood.high}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="day__item day__item--doctor">
|
||||
<span className="day__time">{d.doctor.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="doctor" size={16} />
|
||||
{d.doctor.track}
|
||||
</span>
|
||||
<div className="day__row">
|
||||
<span className="day__text">{d.doctor.text}</span>
|
||||
<span className="spark">
|
||||
{WEEK.map((v, i) => (
|
||||
<i key={i} style={{ height: `${v}%` }} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export function Welcome() {
|
||||
const navigate = useNavigate();
|
||||
const t = ru.welcome;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="welcome" id="main">
|
||||
<div className="welcome__inner">
|
||||
<section className="welcome__intro" aria-labelledby="welcome-title">
|
||||
<h1 id="welcome-title" className="display">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</section>
|
||||
|
||||
<section className="signin" aria-labelledby="choice-title">
|
||||
<h2 id="choice-title" className="signin__title">
|
||||
{t.choose}
|
||||
</h2>
|
||||
|
||||
<article className="option option--emias">
|
||||
<div className="option__head">
|
||||
<span className="option__icon">
|
||||
<Icon name="doctor" size={22} />
|
||||
</span>
|
||||
<h3 className="option__title">{t.emias.title}</h3>
|
||||
<span className="badge">{t.emias.badge}</span>
|
||||
</div>
|
||||
<Facts items={t.emias.facts} />
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={() => navigate("/emias")}>
|
||||
{t.emias.action}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article className="option option--anon">
|
||||
<div className="option__head">
|
||||
<span className="option__icon">
|
||||
<Icon name="lock" size={22} />
|
||||
</span>
|
||||
<h3 className="option__title">{t.anonymous.title}</h3>
|
||||
</div>
|
||||
<Facts items={t.anonymous.facts} />
|
||||
<button type="button" className="btn btn--secondary btn--block" onClick={() => navigate("/anonymous")}>
|
||||
{t.anonymous.action}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="welcome__aside">
|
||||
<DayPreview />
|
||||
<p className="disclaimer">{t.disclaimer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import type { AuthResult } from "../api/types";
|
||||
import { sessionStore, type Session } from "./store";
|
||||
|
||||
interface SessionApi {
|
||||
session: Session | null;
|
||||
signIn: (auth: AuthResult) => void;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<SessionApi | null>(null);
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(() => sessionStore.load());
|
||||
|
||||
const signIn = useCallback((auth: AuthResult) => {
|
||||
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
|
||||
sessionStore.save(next);
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setSession((current) => {
|
||||
sessionStore.clear(current);
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ session, signIn, signOut }), [session, signIn, signOut]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionApi {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error("useSession outside SessionProvider");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AuthMode, Reply } from "../api/types";
|
||||
|
||||
/*
|
||||
* Session persistence behind one small interface. Web uses localStorage; the mobile shell swaps the backend for
|
||||
* secure device storage (e.g. Capacitor Preferences / Keychain) without touching screens.
|
||||
*/
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
mode: AuthMode;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export type ChatEntry =
|
||||
| { id: string; from: "assistant"; reply: Reply; answered: boolean }
|
||||
| { id: string; from: "patient"; text: string };
|
||||
|
||||
interface KeyValue {
|
||||
get(key: string): string | null;
|
||||
set(key: string, value: string): void;
|
||||
remove(key: string): void;
|
||||
}
|
||||
|
||||
const browserStorage: KeyValue = {
|
||||
get: (k) => {
|
||||
try {
|
||||
return window.localStorage.getItem(k);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set: (k, v) => {
|
||||
try {
|
||||
window.localStorage.setItem(k, v);
|
||||
} catch {
|
||||
/* private mode / quota: the session simply is not remembered */
|
||||
}
|
||||
},
|
||||
remove: (k) => {
|
||||
try {
|
||||
window.localStorage.removeItem(k);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const SESSION_KEY = "ptsr.session.v1";
|
||||
const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
|
||||
|
||||
function read<T>(storage: KeyValue, key: string): T | null {
|
||||
const raw = storage.get(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionStore(storage: KeyValue = browserStorage) {
|
||||
return {
|
||||
load: () => read<Session>(storage, SESSION_KEY),
|
||||
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
|
||||
clear: (s: Session | null) => {
|
||||
storage.remove(SESSION_KEY);
|
||||
if (s) storage.remove(chatKey(s.token));
|
||||
},
|
||||
loadChat: (token: string) => read<ChatEntry[]>(storage, chatKey(token)) ?? [],
|
||||
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionStore = createSessionStore();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Design tokens — the single source of colours, type and spacing.
|
||||
* When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a
|
||||
* theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
|
||||
*/
|
||||
:root {
|
||||
/* Base: cool ink on cool white. */
|
||||
--c-ink: #13233a;
|
||||
--c-text: #13233a;
|
||||
--c-text-2: #46566b;
|
||||
--c-text-3: #6b7a8e;
|
||||
--c-page: #f4f7fb;
|
||||
--c-surface: #ffffff;
|
||||
--c-band: #e9f0fb; /* solid tint behind the welcome hero */
|
||||
--c-border: #dbe3ee;
|
||||
--c-border-strong: #b3c0d1;
|
||||
|
||||
/* Care tracks: one colour per thing the service does. The same colour marks the track everywhere
|
||||
(welcome preview, chat questions), so colour carries meaning, never decoration. */
|
||||
--c-meds: #0e8c7f;
|
||||
--c-meds-soft: #e2f4f1;
|
||||
--c-mood: #e79a0c;
|
||||
--c-mood-soft: #fdf1da;
|
||||
--c-mood-ink: #8a5800; /* text on mood-soft (AA) */
|
||||
--c-doctor: #2350c8;
|
||||
--c-doctor-soft: #e7eefc;
|
||||
|
||||
/* Action colour = the doctor/ЕМИАС blue. */
|
||||
--c-primary: #2350c8;
|
||||
--c-primary-hover: #1c43ac;
|
||||
--c-primary-active: #163689;
|
||||
--c-primary-soft: #e7eefc;
|
||||
|
||||
/* Mood scale 1 → 5 (heavy → good); used only for the scale. */
|
||||
--c-scale-1: #d9485f;
|
||||
--c-scale-2: #ef8a3c;
|
||||
--c-scale-3: #f2c230;
|
||||
--c-scale-4: #7cc35b;
|
||||
--c-scale-5: #2fa36b;
|
||||
|
||||
--c-danger: #c8283a;
|
||||
--c-danger-hover: #a91f2f;
|
||||
--c-danger-soft: #fdebed;
|
||||
--c-success: #1e8a55;
|
||||
--c-warning-soft: #fff4dc;
|
||||
--c-warning-border: #e7b448;
|
||||
--c-focus: #3b82f6;
|
||||
|
||||
/* Type: Golos Text — Russian open-source UI typeface (OFL), bundled locally so the app works offline. */
|
||||
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--fs-12: 0.75rem;
|
||||
--fs-14: 0.875rem;
|
||||
--fs-16: 1rem;
|
||||
--fs-18: 1.125rem;
|
||||
--fs-22: 1.375rem;
|
||||
--fs-28: 1.75rem;
|
||||
--fs-40: 2.5rem;
|
||||
--lh-tight: 1.15;
|
||||
--lh-text: 1.5;
|
||||
|
||||
/* Spacing: 4 px grid. */
|
||||
--s-1: 4px;
|
||||
--s-2: 8px;
|
||||
--s-3: 12px;
|
||||
--s-4: 16px;
|
||||
--s-5: 20px;
|
||||
--s-6: 24px;
|
||||
--s-8: 32px;
|
||||
--s-10: 40px;
|
||||
--s-12: 48px;
|
||||
--s-16: 64px;
|
||||
|
||||
/* Radius hierarchy: containers > controls > fields > chips. */
|
||||
--r-card: 14px;
|
||||
--r-control: 10px;
|
||||
--r-field: 8px;
|
||||
--r-chip: 6px;
|
||||
|
||||
--tap: 48px; /* minimum touch target */
|
||||
--header-h: 64px;
|
||||
--content-max: 1160px;
|
||||
|
||||
/* Safe areas (notch, home indicator) for the mobile shell. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// Dev: the API runs on :8000 (uvicorn / docker compose); /api is proxied so the app needs no CORS.
|
||||
// Build output is a static bundle (dist/) — the same bundle is wrapped by Capacitor for the mobile app.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "./",
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { "/api": { target: process.env.API_TARGET ?? "http://localhost:8000", changeOrigin: true } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user