feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile

- /flow renders the dialog engine's current step as a mock screen (consent,
  age, onboarding and scales with progress, mood 0–10, free text, dose
  reminder, crisis with hotlines and breathing, safety plan), so every
  engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
  emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
  clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
  prescription from the dashboard fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:11:45 +03:00
co-authored by Claude Opus 5.5
parent b8a93ce9d4
commit b4b1bc51c8
28 changed files with 3795 additions and 1770 deletions
+466
View File
@@ -0,0 +1,466 @@
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step } from "../dialog/step";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3).
*/
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
return (
<>
{text.split(PHONE_RE).map((part, i) =>
i % 2 === 1 ? (
<a key={i} href={telHref(part)} className="tel">
{part}
</a>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
function Body({ texts }: { texts: string[] }) {
if (!texts.length) return null;
return (
<div className="body-text">
{texts.map((t, i) => {
const ls = lines(t);
return (
<div key={i} className="body-text__block">
{ls.map((l, j) =>
l.list ? (
<ul key={j} className="bullets">
<li>
<RichText text={l.text} />
</li>
</ul>
) : (
<p key={j}>
<RichText text={l.text} />
</p>
),
)}
</div>
);
})}
</div>
);
}
function Progress({ n, total }: { n: number; total: number }) {
return (
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
<span className="progress__bar">
<span style={{ width: `${(n / total) * 100}%` }} />
</span>
<span className="progress__label">{ru.flow.question(n, total)}</span>
</div>
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
<header className="head">
{icon && (
<span className="tile-icon tile-icon--blue">
<Icon name={icon} size={26} />
</span>
)}
{title && <h1 className="h1">{title}</h1>}
<Body texts={lead} />
</header>
);
}
function Choice({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="options" role="radiogroup">
{options.map((b) => {
const on = picked?.data === b.data;
return (
<button
key={b.data}
type="button"
role="radio"
aria-checked={on}
className={on ? "option is-on" : "option"}
onClick={() => setPicked(b)}
>
<span className="option__mark">{on && <Icon name="check" size={16} />}</span>
<span className="option__label">{b.label}</span>
</button>
);
})}
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
function NumberScale({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="numscale">
<span className="numscale__value" aria-live="polite">
{picked ? picked.label : "–"}
</span>
<div className="numscale__row" role="radiogroup">
{options.map((b) => (
<button
key={b.data}
type="button"
role="radio"
aria-checked={picked?.data === b.data}
className={picked?.data === b.data ? "num is-on" : "num"}
onClick={() => setPicked(b)}
>
{b.label}
</button>
))}
</div>
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
function FreeText({
skip,
busy,
onSend,
onSkip,
onExit,
}: {
skip?: DialogButton;
busy: boolean;
onSend: (text: string) => void;
onSkip: (b: DialogButton) => void;
onExit: () => void;
}) {
const [text, setText] = useState("");
return (
<>
<div className="textarea">
<label htmlFor="free" className="visually-hidden">
{ru.messages.inputLabel}
</label>
<textarea
id="free"
rows={5}
maxLength={500}
value={text}
placeholder={ru.flow.textPlaceholder}
onChange={(e) => setText(e.target.value)}
/>
<span className="textarea__count">{text.length}/500</span>
</div>
<div className="btn-row">
{skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
{skip.label}
</button>
) : (
<button type="button" className="btn btn--ghost" onClick={onExit}>
{ru.flow.exit}
</button>
)}
<button
type="button"
className="btn btn--primary"
disabled={!text.trim() || busy}
onClick={() => onSend(text.trim())}
>
{ru.common.next}
</button>
</div>
</>
);
}
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
const style = (b: DialogButton) =>
b.data.startsWith("med:taken")
? { cls: "act act--teal", icon: "check" as IconName }
: b.data.startsWith("med:side_effects")
? { cls: "act act--red", icon: "alert" as IconName }
: { cls: "act", icon: "clock" as IconName };
return (
<div className="acts">
{options.map((b) => {
const s = style(b);
return (
<button key={b.data} type="button" className={s.cls} disabled={busy} onClick={() => onPress(b)}>
<span className="act__icon">
<Icon name={s.icon} size={20} />
</span>
{b.label}
</button>
);
})}
</div>
);
}
function Breathing() {
return (
<section className="breath" aria-label={ru.flow.breathe}>
<Icon name="lungs" size={28} />
<div className="breath__text">
<p className="breath__title">{ru.flow.breathe}</p>
<div className="breath__viz">
<span>{ru.flow.breatheIn}</span>
<span className="breath__ring" aria-hidden="true" />
<span>{ru.flow.breatheOut}</span>
</div>
</div>
</section>
);
}
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
<div className="panel">
<Body texts={notes} />
</div>
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
</div>
);
}
export function Flow() {
const navigate = useNavigate();
const dialog = useDialog();
const { resources } = useHelp();
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
const home = () => navigate("/today");
// First visit after sign-in: the engine opens the dialog (consent → age → onboarding), as /start in Telegram.
useEffect(() => {
if (!dialog.started && !dialog.busy) void dialog.start();
}, []);
useEffect(() => {
dialog.markRead();
}, [dialog.pending]);
useEffect(() => {
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
}, [step, navigate]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
if (!step) {
content = dialog.error ? (
<div className="stack narrow">
<Notice tone="danger">{ru.common.networkError}</Notice>
<button type="button" className="btn btn--primary" onClick={() => void dialog.start()}>
{ru.common.retry}
</button>
</div>
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
} else {
const top = (
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
);
let body: ReactNode;
switch (step.kind) {
case "consent":
body = (
<>
<Head title={ru.flow.consentTitle} lead={[]} icon="shield" />
<div className="panel panel--scroll">
<Body texts={[...step.lead, step.title].filter(Boolean)} />
</div>
<div className="btn-row">
{step.buttons.map((b, i) => (
<button
key={b.data}
type="button"
className={i === 0 ? "btn btn--primary" : "btn btn--outline"}
disabled={dialog.busy}
onClick={() => press(b)}
>
{b.label}
</button>
))}
</div>
</>
);
break;
case "number":
body = (
<>
<Head title={step.title} lead={step.lead} />
<NumberScale key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
</>
);
break;
case "actions":
body = (
<>
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{step.title || ru.meds.reminderTitle}</h1>
<Body texts={step.lead} />
</div>
<MedActions options={step.options} busy={dialog.busy} onPress={press} />
</>
);
break;
case "text":
body = (
<>
<Head title={step.title} lead={step.lead} />
<FreeText
key={stepKey}
skip={step.skip}
busy={dialog.busy}
onSend={say}
onSkip={press}
onExit={home}
/>
</>
);
break;
case "crisis":
body = (
<>
<header className="head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="heart" size={32} />
</span>
<h1 className="h1">{ru.flow.supportTitle}</h1>
</header>
<div className="hotlines">
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} />
))}
</div>
<Breathing />
<div className="panel">
<Body texts={[step.text]} />
</div>
{step.buttons.map((b) => (
<button key={b.data} type="button" className="btn btn--outline" disabled={dialog.busy} onClick={() => press(b)}>
{b.label}
</button>
))}
<button
type="button"
className="row-card"
disabled={dialog.busy}
onClick={() => press({ label: ru.flow.openPlan, data: "safety_plan" })}
>
<Icon name="shield" />
<span className="row-card__text">
<span className="row-card__title">{ru.flow.openPlan}</span>
</span>
<Icon name="chevronRight" />
</button>
</>
);
break;
case "plan":
body = (
<>
<Head title={step.title || ru.flow.planTitle} lead={step.lead} />
<ul className="plan">
{step.lines.map((l) => (
<li key={l} className="plan__item">
<Icon name="shield" size={20} />
<span>{l}</span>
</li>
))}
</ul>
<button type="button" className="btn btn--primary" onClick={home}>
{ru.common.toToday}
</button>
</>
);
break;
case "choice":
body = (
<>
<Head title={step.title} lead={step.lead} />
<Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
</>
);
break;
}
content = (
<div className="stack narrow" key={stepKey}>
{top}
{body}
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
);
}
return (
<AppShell focus sos={step?.kind !== "crisis"}>
{content}
</AppShell>
);
}