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 ? (
{part}
) : (
{part}
),
)}
>
);
}
function Body({ texts }: { texts: string[] }) {
if (!texts.length) return null;
return (
{texts.map((t, i) => {
const ls = lines(t);
return (
{ls.map((l, j) =>
l.list ? (
) : (
),
)}
);
})}
);
}
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 (
{ru.flow.questionN(n)}
);
return (
{ru.flow.question(n, total)}
);
}
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable }) {
return (
{ru.flow.drinks}
{ru.flow.drinksLead}
СП
{table.header.map((h) => (
{h}
))}
{table.rows.map(([label, cells]) => (
{label.replace(/\s*СП$/, "")}
{cells.map((c, i) => (
{c}
))}
))}
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
{icon && (
)}
{title && {title} }
);
}
function Choice({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState(null);
return (
<>
{options.map((b) => {
const on = picked?.data === b.data;
return (
setPicked(b)}
>
{on && }
{b.label}
);
})}
picked && onSubmit(picked)}
>
{ru.common.next}
>
);
}
function NumberScale({
options,
busy,
onSubmit,
anchors,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
anchors?: string[];
}) {
const [picked, setPicked] = useState(null);
return (
<>
{picked ? picked.label : "–"}
{options.map((b) => (
setPicked(b)}
>
{b.label}
))}
{anchors && anchors.length === 2 && (
{anchors[0]}
{anchors[1]}
)}
picked && onSubmit(picked)}
>
{ru.common.next}
>
);
}
// 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 (
<>
{ru.messages.inputLabel}
{skip ? (
onSkip(skip)}>
{skip.label}
) : (
{ru.flow.exit}
)}
(text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
>
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
>
);
}
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 (
);
}
// 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 (
<>
{step.instruction.length > 0 && (
)}
{step.title}
{step.footnotes.map((f) => (
{f}
))}
{step.itemId === "rus_audit_2" && 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 = ;
break;
case "hours":
case "portions":
answer = ;
break;
case "text":
answer = (
);
break;
default:
answer = (
<>
{step.skip && (
press(step.skip!)}>
{step.skip.label}
)}
>
);
}
return (
<>
{answer}
{step.pause && (
onPause(step.pause!)}>
{step.pause.label}
)}
{step.help && (
press(step.help!)}>
{step.help.label}
)}
>
);
}
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 (
{options.map((b) => {
const s = style(b);
return (
onPress(b)}>
{b.label}
);
})}
);
}
function Breathing() {
return (
{ru.flow.breathe}
{ru.flow.breatheIn}
{ru.flow.breatheOut}
);
}
// 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 (
{head}
{rest.length > 0 && (
)}
{ru.common.toToday}
{ru.today.results}
);
}
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 ? (
{ru.common.networkError}
void dialog.start()}>
{ru.common.retry}
) : (
);
} else if (step.kind === "ack") {
content =
;
} else if (step.kind === "idle") {
content = navigate("/results")} />;
} else if (step.kind === "notice") {
content = (
{step.primary && (
press(step.primary!)}>
{step.primary.label}
)}
{step.help && (
press(step.help!)}>
{step.help.label}
)}
{ru.common.toToday}
);
} else {
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
const progress =
step.kind === "choice" && step.progress ? (
) : item ? (
) : null;
const top = progress ? {progress} : 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 = (
<>
>
);
act = (
{step.buttons.map((b, i) => (
press(b)}
>
{b.label}
))}
);
break;
case "number":
lead = ;
act = ;
break;
case "actions":
lead = (
{step.title || ru.meds.reminderTitle}
);
act = ;
break;
case "text":
lead = ;
act = (
);
break;
case "crisis":
lead = (
<>
>
);
act = (
<>
{resources.map((r) => (
))}
{step.buttons.map((b) => (
press(b)}>
{b.label}
))}
press({ label: ru.flow.openPlan, data: "safety_plan" })}
>
{ru.flow.openPlan}
>
);
break;
case "plan":
lead = ;
act = (
<>
{step.lines.map((l) => (
{l}
))}
{ru.common.toToday}
>
);
break;
case "survey":
lead = ;
act = ;
break;
case "choice":
lead =