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