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