CI / ruff · pytest (pull_request) Successful in 2m28s
Keep the shared dialog and submitted answers while leaving the focus screen. Remove the history trap and wire the text-step home action. Refs #85
731 lines
23 KiB
TypeScript
731 lines
23 KiB
TypeScript
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
|
||
import type { Catalog, DialogButton } from "../api/types";
|
||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
|
||
import { useToast } from "../components/Toast";
|
||
import { Icon, type IconName } from "../components/Icon";
|
||
import { useCatalog } from "../content/reference";
|
||
import { ru } from "../content/ru";
|
||
import { useDialog } from "../dialog/DialogContext";
|
||
import { classify, lines, type Step, type Survey } 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),
|
||
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
|
||
*/
|
||
|
||
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, adaptive = false }: { n: number; total: number; adaptive?: boolean }) {
|
||
// Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar.
|
||
if (adaptive)
|
||
return (
|
||
<div className="progress">
|
||
<span className="progress__label">{ru.flow.questionN(n)}</span>
|
||
</div>
|
||
);
|
||
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>
|
||
);
|
||
}
|
||
|
||
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
|
||
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
|
||
return (
|
||
<section className="drinks" aria-label={ru.flow.drinks}>
|
||
<h2 className="h3">{ru.flow.drinks}</h2>
|
||
<p className="muted small">{ru.flow.drinksLead}</p>
|
||
<div className="drinks__scroll">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">СП</th>
|
||
{table.header.map((h) => (
|
||
<th key={h} scope="col">
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{table.rows.map(([label, cells]) => (
|
||
<tr key={label}>
|
||
<th scope="row">{label.replace(/\s*СП$/, "")}</th>
|
||
{cells.map((c, i) => (
|
||
<td key={i}>{c}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="muted small">
|
||
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
|
||
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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,
|
||
anchors,
|
||
}: {
|
||
options: DialogButton[];
|
||
busy: boolean;
|
||
onSubmit: (b: DialogButton) => void;
|
||
anchors?: string[];
|
||
}) {
|
||
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" style={{ "--n": options.length } as CSSProperties}>
|
||
{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>
|
||
{anchors && anchors.length === 2 && (
|
||
<div className="numscale__ends">
|
||
<span>{anchors[0]}</span>
|
||
<span>{anchors[1]}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn--primary"
|
||
disabled={!picked || busy}
|
||
onClick={() => picked && onSubmit(picked)}
|
||
>
|
||
{ru.common.next}
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
|
||
const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
|
||
|
||
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" disabled={busy} onClick={onExit}>
|
||
{ru.flow.exit}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn--primary"
|
||
disabled={busy}
|
||
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
|
||
>
|
||
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
|
||
</button>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const NUMERIC = {
|
||
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
|
||
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
|
||
};
|
||
|
||
function HoursInput({
|
||
busy,
|
||
onSend,
|
||
kind = "hours",
|
||
}: {
|
||
busy: boolean;
|
||
onSend: (text: string) => void;
|
||
kind?: keyof typeof NUMERIC;
|
||
}) {
|
||
const f = NUMERIC[kind];
|
||
const [v, setV] = useState("");
|
||
const n = Number(v.replace(",", "."));
|
||
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
|
||
return (
|
||
<form
|
||
className="hours"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (ok) onSend(v.trim());
|
||
}}
|
||
>
|
||
<label htmlFor="hours" className="hours__label">
|
||
{f.label}
|
||
</label>
|
||
<div className="hours__field">
|
||
<input
|
||
id="hours"
|
||
inputMode="decimal"
|
||
autoComplete="off"
|
||
value={v}
|
||
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
|
||
aria-describedby="hours-hint"
|
||
/>
|
||
<span aria-hidden="true">{f.unit}</span>
|
||
</div>
|
||
<p id="hours-hint" className="muted small">
|
||
{f.hint}
|
||
</p>
|
||
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
|
||
{ru.common.next}
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
// No form name or section above the question: the person should not see what a questionnaire screens for while
|
||
// answering it. Names and results appear only after it is finished (Results screen).
|
||
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
|
||
return (
|
||
<>
|
||
<header className="head survey">
|
||
{step.instruction.length > 0 && (
|
||
<div className="survey__instr">
|
||
<Body texts={step.instruction} />
|
||
</div>
|
||
)}
|
||
<h1 className="h1 survey__q">{step.title}</h1>
|
||
{step.footnotes.map((f) => (
|
||
<p key={f} className="muted small">
|
||
{f}
|
||
</p>
|
||
))}
|
||
</header>
|
||
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SurveyAct({
|
||
step,
|
||
stepKey,
|
||
busy,
|
||
press,
|
||
say,
|
||
onPause,
|
||
onHome,
|
||
}: {
|
||
step: Survey;
|
||
stepKey: string;
|
||
busy: boolean;
|
||
press: (b: DialogButton) => void;
|
||
say: (text: string) => void;
|
||
onPause: (b: DialogButton) => void;
|
||
onHome: () => void;
|
||
}) {
|
||
let answer: ReactNode;
|
||
switch (step.input) {
|
||
case "number":
|
||
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
|
||
break;
|
||
case "hours":
|
||
case "portions":
|
||
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
|
||
break;
|
||
case "text":
|
||
answer = (
|
||
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={onHome} />
|
||
);
|
||
break;
|
||
default:
|
||
answer = (
|
||
<>
|
||
<Choice key={stepKey} options={step.options} busy={busy} onSubmit={press} />
|
||
{step.skip && (
|
||
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => press(step.skip!)}>
|
||
{step.skip.label}
|
||
</button>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
{answer}
|
||
<div className="survey__aux">
|
||
{step.pause && (
|
||
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
|
||
<Icon name="pause" size={18} />
|
||
{step.pause.label}
|
||
</button>
|
||
)}
|
||
{step.help && (
|
||
<button type="button" className="linkbtn linkbtn--danger" disabled={busy} onClick={() => press(step.help!)}>
|
||
<Icon name="alert" size={18} />
|
||
{step.help.label}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
|
||
// explains it. Results stay in «Мои результаты» with the date.
|
||
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
|
||
const [head = ru.flow.doneTitle, ...rest] = notes
|
||
.join("\n\n")
|
||
.split(/\n{2,}/)
|
||
.map((p) => p.trim())
|
||
.filter(Boolean);
|
||
return (
|
||
<div className="stack narrow done">
|
||
<span className="done__badge" aria-hidden="true">
|
||
<Icon name="check" size={40} />
|
||
</span>
|
||
<h1 className="h1 center">{head}</h1>
|
||
{rest.length > 0 && (
|
||
<div className="panel">
|
||
<Body texts={rest} />
|
||
</div>
|
||
)}
|
||
<button type="button" className="btn btn--primary" onClick={onHome}>
|
||
{ru.common.toToday}
|
||
</button>
|
||
<button type="button" className="btn btn--soft" onClick={onResults}>
|
||
{ru.today.results}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const SHORT_PARAGRAPHS = 3;
|
||
|
||
export function Flow() {
|
||
const navigate = useNavigate();
|
||
const toast = useToast();
|
||
const dialog = useDialog();
|
||
const { resources } = useHelp();
|
||
const catalog = useCatalog();
|
||
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]);
|
||
|
||
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
|
||
// result (the conclusion of the first questionnaire) keeps its own screen.
|
||
const toasted = useRef("");
|
||
useEffect(() => {
|
||
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
|
||
const texts = step.kind === "ack" ? step.text : step.notes;
|
||
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
||
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
|
||
if (paragraphs.length && toasted.current !== stepKey) {
|
||
toasted.current = stepKey;
|
||
toast(paragraphs.join(" "));
|
||
}
|
||
navigate("/today", { replace: true });
|
||
}, [step, stepKey, navigate, toast]);
|
||
|
||
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
|
||
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
|
||
const pause = (b: DialogButton) =>
|
||
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
|
||
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 === "ack") {
|
||
content = <div className="loader" aria-label="Загрузка" />;
|
||
} else if (step.kind === "idle") {
|
||
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
|
||
} else if (step.kind === "notice") {
|
||
content = (
|
||
<div className="stack narrow done">
|
||
<span className="tile-icon tile-icon--blue tile-icon--xl center-self" aria-hidden="true">
|
||
<Icon name={step.help ? "shield" : "pause"} size={32} />
|
||
</span>
|
||
<h1 className="h1 center">{ru.flow.noticeTitle}</h1>
|
||
<div className="panel">
|
||
<Body texts={step.text} />
|
||
</div>
|
||
{step.primary && (
|
||
<button type="button" className="btn btn--primary" disabled={dialog.busy} onClick={() => press(step.primary!)}>
|
||
{step.primary.label}
|
||
</button>
|
||
)}
|
||
{step.help && (
|
||
<button type="button" className="btn btn--danger" disabled={dialog.busy} onClick={() => press(step.help!)}>
|
||
{step.help.label}
|
||
</button>
|
||
)}
|
||
<button type="button" className="btn btn--soft" onClick={home}>
|
||
{ru.common.toToday}
|
||
</button>
|
||
</div>
|
||
);
|
||
} else {
|
||
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
|
||
const progress =
|
||
step.kind === "choice" && step.progress ? (
|
||
<Progress {...step.progress} />
|
||
) : item ? (
|
||
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
|
||
) : null;
|
||
const top = progress ? <TopBar>{progress}</TopBar> : null;
|
||
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
|
||
let lead: ReactNode;
|
||
let act: ReactNode;
|
||
switch (step.kind) {
|
||
case "consent":
|
||
lead = (
|
||
<>
|
||
<Head title={ru.flow.consentTitle} lead={[]} icon="shield" />
|
||
<div className="panel panel--scroll">
|
||
<Body texts={[...step.lead, step.title].filter(Boolean)} />
|
||
</div>
|
||
</>
|
||
);
|
||
act = (
|
||
<div className="btn-row btn-row--stack-desk">
|
||
{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":
|
||
lead = <Head title={step.title} lead={step.lead} icon="smile" />;
|
||
act = <NumberScale key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
|
||
break;
|
||
case "actions":
|
||
lead = (
|
||
<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>
|
||
);
|
||
act = <MedActions options={step.options} busy={dialog.busy} onPress={press} />;
|
||
break;
|
||
case "text":
|
||
lead = <Head title={step.title} lead={step.lead} icon="edit" />;
|
||
act = (
|
||
<FreeText key={stepKey} skip={step.skip} busy={dialog.busy} onSend={say} onSkip={press} onExit={home} />
|
||
);
|
||
break;
|
||
case "crisis":
|
||
lead = (
|
||
<>
|
||
<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="panel">
|
||
<Body texts={[step.text]} />
|
||
</div>
|
||
</>
|
||
);
|
||
act = (
|
||
<>
|
||
<div className="hotlines">
|
||
{resources.map((r) => (
|
||
<HotlineCard key={r.phone} r={r} />
|
||
))}
|
||
</div>
|
||
<Breathing />
|
||
{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":
|
||
lead = <Head title={step.title || ru.flow.planTitle} lead={step.lead} icon="shield" />;
|
||
act = (
|
||
<>
|
||
<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 "survey":
|
||
lead = <SurveyLead step={step} catalog={catalog} />;
|
||
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} onHome={home} />;
|
||
break;
|
||
case "choice":
|
||
lead = <Head title={step.title} lead={step.lead} />;
|
||
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
|
||
break;
|
||
}
|
||
content = (
|
||
<div className="focus" key={stepKey}>
|
||
{top}
|
||
<div className={`split split--${step.kind}${step.kind === "survey" ? ` split--in-${step.input}` : ""}`}>
|
||
<div className="split__lead">{lead}</div>
|
||
<div className="split__act">
|
||
{act}
|
||
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AppShell focus bare sos={step?.kind !== "crisis"}>
|
||
<nav className="flow-nav" aria-label={ru.flow.home}>
|
||
{/* Leaving does not send an answer or clear pending: Today can reopen this same question.
|
||
Wait for an in-flight answer before leaving so its result has reached the shared dialog. */}
|
||
<button type="button" className="btn btn--ghost btn--auto" disabled={dialog.busy} onClick={home}>
|
||
<Icon name="home" size={20} />
|
||
{ru.flow.home}
|
||
</button>
|
||
</nav>
|
||
{content}
|
||
</AppShell>
|
||
);
|
||
}
|