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:
2026-10-04 07:57:00 +03:00
co-authored by Claude Opus 5.5
parent 4d331e4c05
commit b8a93ce9d4
28 changed files with 4697 additions and 5 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+43
View File
@@ -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).
## Известные ограничения прототипа
- Токен пациента используется как учётные данные сессии; в проде — короткоживущие серверные сессии.
- Подбор кодов не ограничен по частоте.
- В демо нет сида кодов подключения: для показа используйте демо-пациента.
+15
View File
@@ -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>
+1802
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+1
View File
@@ -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

+42
View File
@@ -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,
};
+41
View File
@@ -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;
}
+64
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+103
View File
@@ -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;
+39
View File
@@ -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>,
);
+61
View File
@@ -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>
);
}
+267
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+145
View File
@@ -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>
);
}
+38
View File
@@ -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;
}
+74
View File
@@ -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
+90
View File
@@ -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);
}
+18
View File
@@ -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"]
}
+13
View File
@@ -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 } },
},
});