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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user