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