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
+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>
);
}