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
+56 -32
View File
@@ -2,11 +2,15 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { AppShell, BackLink, Notice } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
const NO_ICONS: IconName[] = ["user", "doc"];
// A3 · Анонимный вход
export function AnonymousSignIn() {
const t = ru.anonymous;
const navigate = useNavigate();
@@ -19,7 +23,7 @@ export function AnonymousSignIn() {
setError(false);
try {
signIn(await api.signInAnonymous());
navigate("/chat", { replace: true });
navigate("/flow", { replace: true });
} catch {
setError(true);
setBusy(false);
@@ -27,35 +31,55 @@ export function AnonymousSignIn() {
};
return (
<AppShell>
<main className="page" id="main">
<div className="narrow">
<BackLink to="/">{t.back}</BackLink>
<section className="panel" aria-labelledby="anon-title">
<h1 id="anon-title" className="h2">
{t.title}
</h1>
<p className="lead lead--sm">{t.lead}</p>
<ul className="facts facts--spaced">
{t.points.map((p) => (
<li key={p} className="fact">
<Icon name="check" size={18} />
<span>{p}</span>
</li>
))}
</ul>
<Notice tone="warning">{t.warning}</Notice>
{error && (
<p className="field__error" role="alert">
{ru.emias.networkError}
</p>
)}
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}>
{busy ? t.submitting : t.submit}
</button>
</section>
</div>
</main>
<AppShell signedIn={false} focus>
<div className="stack narrow">
<TopBar onBack={() => navigate("/")} />
<header className="head">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<section className="panel panel--teal">
<p className="panel__title">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
{t.yesTitle}
</p>
<ul className="iconlist">
{t.yes.map((item, i) => (
<li key={item}>
<Icon name={YES_ICONS[i]} size={20} />
{item}
</li>
))}
</ul>
</section>
<section className="panel panel--red">
<p className="panel__title">
<span className="badge-icon badge-icon--red">
<Icon name="x" size={16} />
</span>
{t.noTitle}
</p>
<ul className="iconlist">
{t.no.map((item, i) => (
<li key={item}>
<Icon name={NO_ICONS[i]} size={20} />
{item}
</li>
))}
</ul>
</section>
<Notice tone="warn">{t.warning}</Notice>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<button type="button" className="btn btn--primary" onClick={start} disabled={busy}>
{busy ? t.submitting : t.submit}
</button>
</div>
</AppShell>
);
}
-267
View File
@@ -1,267 +0,0 @@
import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { DialogButton, DialogEvent, Reply } from "../api/types";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { sessionStore, type ChatEntry } from "../session/store";
// The engine pushes reminders and check-ins to the outbox; the web channel polls it (Telegram gets them pushed).
const OUTBOX_POLL_MS = 6000;
let seq = 0;
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
const assistantEntries = (replies: Reply[]): ChatEntry[] =>
replies.map((reply) => ({ id: newId(), from: "assistant", reply, answered: false }));
// Phone numbers in engine texts (crisis script, help lines) become tap-to-call links.
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|8 \(\d{3}\) \d{3}|\b11[2]\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
return (
<>
{text.split("\n").map((line, i) => (
<Fragment key={i}>
{i > 0 && <br />}
{line.split(PHONE_RE).map((part, j) =>
j % 2 === 1 ? (
<a key={j} href={`tel:${part.replace(/[^\d+]/g, "")}`} className="tel">
{part}
</a>
) : (
<Fragment key={j}>{part}</Fragment>
),
)}
</Fragment>
))}
</>
);
}
function AssistantMessage({
entry,
disabled,
onPress,
}: {
entry: Extract<ChatEntry, { from: "assistant" }>;
disabled: boolean;
onPress: (button: DialogButton) => void;
}) {
const { reply } = entry;
const special = reply.kind === "crisis" || reply.kind === "safety_plan";
return (
<li className={`msg msg--assistant msg--${reply.kind}`}>
<div className="msg__bubble">
{special && (
<p className="msg__label">
<Icon name="alert" size={16} />
{ru.chat.crisisLabel}
</p>
)}
<p className="msg__text">
<RichText text={reply.text} />
</p>
</div>
{reply.buttons.length > 0 && !entry.answered && (
<div className="choices">
{reply.buttons.map((row, r) => (
<div key={r} className="choices__row">
{row.map((b) => (
<button
key={b.data}
type="button"
className="choice"
disabled={disabled}
onClick={() => onPress(b)}
>
{b.label}
</button>
))}
</div>
))}
</div>
)}
</li>
);
}
export function Chat() {
const t = ru.chat;
const navigate = useNavigate();
const { session, signOut } = useSession();
const token = session?.token ?? "";
const [entries, setEntries] = useState<ChatEntry[]>(() => (token ? sessionStore.loadChat(token) : []));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
const started = useRef(false);
useEffect(() => {
if (token) sessionStore.saveChat(token, entries);
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, [entries, token]);
// Any patient action closes the open buttons: the engine has moved on, old choices are stale.
const dispatch = useCallback(
async (event: DialogEvent, optimistic: ChatEntry[]) => {
setBusy(true);
setError(false);
let reopened = new Set<string>();
setEntries((prev) => {
reopened = new Set(prev.filter((e) => e.from === "assistant" && !e.answered).map((e) => e.id));
return [...prev.map((e) => (reopened.has(e.id) ? { ...e, answered: true } : e)), ...optimistic];
});
try {
const replies = await api.sendEvent(token, event);
setEntries((prev) => [...prev, ...assistantEntries(replies)]);
return true;
} catch {
// Roll back the optimistic patient message and re-open the buttons.
const ids = new Set(optimistic.map((o) => o.id));
setEntries((prev) =>
prev.filter((e) => !ids.has(e.id)).map((e) => (reopened.has(e.id) ? { ...e, answered: false } : e)),
);
setError(true);
return false;
} finally {
setBusy(false);
}
},
[token],
);
// First visit: the engine opens the dialog (consent → onboarding) exactly as for /start in Telegram.
useEffect(() => {
if (!token || started.current) return;
started.current = true;
if (entries.length === 0) void dispatch({ type: "start" }, []);
}, [token, entries.length, dispatch]);
useEffect(() => {
if (!token) return;
const timer = window.setInterval(() => {
api
.outbox(token)
.then((replies) => replies.length && setEntries((prev) => [...prev, ...assistantEntries(replies)]))
.catch(() => undefined);
}, OUTBOX_POLL_MS);
return () => window.clearInterval(timer);
}, [token]);
useEffect(() => {
if (!session) navigate("/", { replace: true });
}, [session, navigate]);
if (!session) return null;
const sendText = async (e?: FormEvent) => {
e?.preventDefault();
const text = draft.trim();
if (!text || busy) return;
setDraft("");
const ok = await dispatch({ type: "text", text }, [{ id: newId(), from: "patient", text }]);
if (!ok) setDraft(text);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) void sendText();
};
const press = (b: DialogButton) =>
void dispatch({ type: "button", button: b.data }, [{ id: newId(), from: "patient", text: b.label }]);
return (
<AppShell footer={false}>
<div className="chat-bar">
<div className="chat-bar__inner">
<span className={`mode mode--${session.mode}`}>
<Icon name={session.mode === "emias" ? "doctor" : "lock"} size={16} />
{session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
{session.displayName && <span className="mode__name">· {session.displayName}</span>}
</span>
{confirmLogout ? (
<span className="logout-confirm" role="group" aria-label={t.logout}>
<span className="logout-confirm__text">{t.logoutConfirm}</span>
<button type="button" className="btn btn--danger btn--sm" onClick={signOut}>
{t.logout}
</button>
<button type="button" className="icon-btn" aria-label={ru.help.close} onClick={() => setConfirmLogout(false)}>
<Icon name="close" size={18} />
</button>
</span>
) : (
<button
type="button"
className="link-btn"
onClick={() => (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
>
<Icon name="logout" size={18} />
{t.logout}
</button>
)}
</div>
</div>
<main className="chat" id="main">
<ol className="messages" aria-live="polite" aria-label={t.assistant}>
{entries.map((entry) =>
entry.from === "assistant" ? (
<AssistantMessage key={entry.id} entry={entry} disabled={busy} onPress={press} />
) : (
<li key={entry.id} className="msg msg--patient">
<span className="visually-hidden">{t.you}: </span>
<div className="msg__bubble">
<p className="msg__text">{entry.text}</p>
</div>
</li>
),
)}
{busy && (
<li className="msg msg--assistant msg--typing" aria-label={t.loading}>
<div className="msg__bubble">
<span className="dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</div>
</li>
)}
</ol>
<div ref={endRef} />
</main>
<form className="composer" onSubmit={sendText}>
{error && (
<p className="composer__error" role="alert">
{t.sendError}
</p>
)}
<div className="composer__row">
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
className="composer__input"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<button type="submit" className="send-btn" disabled={busy || !draft.trim()} aria-label={t.send}>
<Icon name="send" size={22} />
</button>
</div>
</form>
</AppShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useNavigate } from "react-router-dom";
import { AppShell, Notice } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { humanDate, useToday } from "./useToday";
// F1 / F2 · Врач: следующий визит и связь с врачом (действия выполняет движок диалога)
export function Doctor() {
const t = ru.doctor;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
const actions: { button: string; label: string; icon: IconName }[] = [
{ button: "menu:contact_doctor", label: t.write, icon: "message" },
{ button: "menu:visit", label: t.earlier, icon: "calendar" },
{ button: "safety_plan", label: t.plan, icon: "shield" },
];
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
</header>
{plan && !plan.has_doctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<>
<section className="card-list" aria-label={t.visit}>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span>
<span className="row-card__sub">{t.visit}</span>
<span className="row-card__title">{plan?.next_visit ? humanDate(plan.next_visit) : t.visitNone}</span>
</span>
</div>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
<Icon name="doctor" />
</span>
<span className="row-card__title">{t.attending}</span>
</div>
</section>
<div className="stack stack--sm">
{actions.map((a) => (
<button key={a.button} type="button" className="row-card" onClick={() => go(a.button, a.label)} disabled={dialog.busy}>
<span className="tile-icon">
<Icon name={a.icon} />
</span>
<span className="row-card__text">
<span className="row-card__title">{a.label}</span>
</span>
<Icon name="chevronRight" />
</button>
))}
</div>
<Notice>{t.writeNote}</Notice>
</>
)}
</div>
</AppShell>
);
}
+58 -74
View File
@@ -3,11 +3,12 @@ import { useNavigate } from "react-router-dom";
import { ApiError, api } from "../api/client";
import type { AuthResult, DemoPersona } from "../api/types";
import { AppShell, BackLink, Notice } from "../components/AppShell";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
// A2 · Вход через ЕМИАС (демо: одноразовый код от врача или синтетический пациент)
export function EmiasSignIn() {
const t = ru.emias;
const navigate = useNavigate();
@@ -26,11 +27,11 @@ export function EmiasSignIn() {
const finish = (auth: AuthResult) => {
signIn(auth);
navigate("/chat", { replace: true });
navigate("/flow", { replace: true });
};
const fail = (e: unknown) => {
setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
setError(e instanceof ApiError && e.status === 401 ? t.codeError : ru.common.networkError);
setBusy(false);
};
@@ -57,81 +58,64 @@ export function EmiasSignIn() {
};
return (
<AppShell>
<main className="page" id="main">
<div className="narrow">
<BackLink to="/">{t.back}</BackLink>
<section className="panel" aria-labelledby="emias-title">
<h1 id="emias-title" className="h2">
{t.title}
</h1>
<Notice>{t.demoNotice}</Notice>
<AppShell signedIn={false} focus>
<div className="stack narrow">
<TopBar onBack={() => navigate("/")} />
<header className="head">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<form className="form" onSubmit={submit} noValidate>
<div className={error ? "field field--error" : "field"}>
<label htmlFor="code" className="field__label">
{t.codeLabel}
</label>
<input
id="code"
className="input"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t.codePlaceholder}
autoComplete="one-time-code"
autoCapitalize="characters"
spellCheck={false}
maxLength={32}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "code-error" : "code-hint"}
/>
{error ? (
<p id="code-error" className="field__error" role="alert">
{error}
</p>
) : (
<p id="code-hint" className="field__hint">
{t.codeHint}
</p>
)}
</div>
<button type="submit" className="btn btn--primary btn--block" disabled={busy || !code.trim()}>
{busy ? t.submitting : t.submit}
</button>
</form>
<div className="divider" role="separator">
<span>{t.or}</span>
<form className="stack stack--sm" onSubmit={submit} noValidate>
<label htmlFor="code" className="visually-hidden">
{t.codeLabel}
</label>
<input
id="code"
className={error ? "code-input is-error" : "code-input"}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t.codePlaceholder}
autoComplete="one-time-code"
autoCapitalize="characters"
spellCheck={false}
maxLength={32}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "code-error" : undefined}
/>
{error && (
<div id="code-error" role="alert">
<Notice tone="danger">{error}</Notice>
</div>
)}
<button type="submit" className="btn btn--primary" disabled={busy || !code.trim()}>
{busy ? t.submitting : t.submit}
</button>
</form>
<h2 className="h3">{t.demoTitle}</h2>
<p className="muted">{t.demoLead}</p>
{personas === null ? null : personas.length === 0 ? (
<p className="muted">{t.demoEmpty}</p>
) : (
<ul className="list">
{personas.map((p) => (
<li key={p.token}>
<button type="button" className="list-row" onClick={() => demo(p)} disabled={busy}>
<span className="avatar" aria-hidden="true">
{p.display_name.charAt(0)}
</span>
<span className="list-row__text">
<span className="list-row__title">{p.display_name}</span>
<span className="list-row__sub">{t.demoDay(p.therapy_day)}</span>
</span>
<span className="list-row__action">
{t.demoAction}
<Icon name="arrowRight" size={18} />
</span>
</button>
</li>
))}
</ul>
)}
</section>
<Notice>{t.demoNotice}</Notice>
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
</main>
<section className="stack stack--sm" aria-labelledby="demo-title">
<h2 id="demo-title" className="h3">
{t.demoTitle}
</h2>
<p className="muted">{t.demoLead}</p>
{personas?.map((p) => (
<button key={p.token} type="button" className="row-card" onClick={() => demo(p)} disabled={busy}>
<span className="avatar">{p.display_name.charAt(0)}</span>
<span className="row-card__text">
<span className="row-card__title">{p.display_name}</span>
<span className="row-card__sub">{t.demoDay(p.therapy_day)}</span>
</span>
<Icon name="chevronRight" />
</button>
))}
</section>
</div>
</AppShell>
);
}
+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>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useNavigate, useParams } from "react-router-dom";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useToday } from "./useToday";
const DOT: Record<string, { cls: string; icon?: IconName }> = {
taken: { cls: "wk wk--taken", icon: "check" },
missed: { cls: "wk wk--missed", icon: "x" },
pending: { cls: "wk wk--pending" },
none: { cls: "wk" },
};
// E4 · Моя схема приёма
export function Meds() {
const t = ru.meds;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const ask = () =>
void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow"));
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
{plan && plan.prescriptions.length === 0 && <Notice>{t.empty}</Notice>}
{plan?.prescriptions.map((p) => (
<article key={p.drug} className="rx">
<div className="rx__head">
<span className="tile-icon tile-icon--teal">
<Icon name="pill" />
</span>
<span>
<span className="rx__drug">{p.drug.charAt(0).toUpperCase() + p.drug.slice(1)}</span>
<span className="rx__by">
{p.dose} · {t.byDoctor}
</span>
</span>
</div>
<ul className="rx__times">
{p.times.map((time) => (
<li key={time}>
<Icon name="clock" size={18} />
<strong>{time}</strong>
<span>{t.tablet}</span>
</li>
))}
</ul>
<ol className="week" aria-label="Отметки за неделю">
{p.week.map((w, i) => (
<li key={w.day} className="week__day">
<span className="week__label">{t.week[i]}</span>
<span className={DOT[w.status].cls} aria-label={w.status}>
{DOT[w.status].icon && <Icon name={DOT[w.status].icon!} size={14} />}
</span>
</li>
))}
</ol>
</article>
))}
{plan && plan.prescriptions.length > 0 && <Notice>{t.onlyDoctor}</Notice>}
{plan?.has_doctor && (
<button type="button" className="btn btn--outline" onClick={ask} disabled={dialog.busy}>
<Icon name="question" />
{t.question}
</button>
)}
</div>
</AppShell>
);
}
// E1 · Напоминание о приёме (opened from «Сегодня»); answers go to the engine like the Telegram reminder buttons.
export function DoseReminder() {
const t = ru.meds;
const { id } = useParams();
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const dose = plan?.doses.find((d) => String(d.id) === id);
const answer = (data: string, label: string) =>
void dialog.send({ type: "button", button: `${data}:${id}` }, label).then((ok) => ok && navigate("/flow"));
return (
<AppShell focus>
<div className="stack narrow">
<TopBar onBack={() => navigate("/today")} />
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{t.reminderTitle}</h1>
<p className="muted center">{t.reminderLead}</p>
{dose && (
<div className="reminder__time">
<Icon name="clock" />
<span>{dose.time}</span>
</div>
)}
{dose && (
<p className="reminder__drug">
{dose.drug.charAt(0).toUpperCase() + dose.drug.slice(1)}, {dose.dose}
</p>
)}
</div>
<div className="acts">
<button type="button" className="act act--teal" disabled={dialog.busy} onClick={() => answer("med:taken", t.taken)}>
<span className="act__icon">
<Icon name="check" size={20} />
</span>
{t.taken}
</button>
<button type="button" className="act" disabled={dialog.busy} onClick={() => answer("med:skip", t.skip)}>
<span className="act__icon">
<Icon name="clock" size={20} />
</span>
{t.skip}
</button>
<button
type="button"
className="act act--red"
disabled={dialog.busy}
onClick={() => answer("med:side_effects", t.sideEffects)}
>
<span className="act__icon">
<Icon name="alert" size={20} />
</span>
{t.sideEffects}
</button>
</div>
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</AppShell>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
// I1 · Сообщения: вся переписка с помощником; быстрые ответы — кнопки последнего сообщения движка.
export function Messages() {
const t = ru.messages;
const navigate = useNavigate();
const dialog = useDialog();
const [draft, setDraft] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const quick = dialog.pending.flatMap((r) => r.buttons.flat());
useEffect(() => {
dialog.markRead();
endRef.current?.scrollIntoView({ block: "end" });
}, [dialog.entries.length]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
const text = draft.trim();
if (!text || dialog.busy) return;
setDraft("");
if (!(await dialog.send({ type: "text", text }, text))) setDraft(text);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send();
}
};
return (
<AppShell>
<div className="stack chat">
<TopBar onBack={() => navigate("/today")} title={t.title} />
{dialog.entries.length === 0 && <p className="muted">{t.empty}</p>}
<ol className="bubbles" aria-live="polite">
{dialog.entries.map((e) =>
e.from === "assistant" ? (
<li key={e.id} className={`bubble bubble--bot bubble--${e.reply.kind}`}>
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<p>{e.reply.text}</p>
</li>
) : (
<li key={e.id} className="bubble bubble--me">
<span className="visually-hidden">{t.you}: </span>
<p>{e.text}</p>
</li>
),
)}
</ol>
{quick.length > 0 && (
<div className="quickreplies">
{quick.map((b) => (
<button
key={b.data}
type="button"
className="qr"
disabled={dialog.busy}
onClick={() => void dialog.send({ type: "button", button: b.data }, b.label)}
>
{b.label}
</button>
))}
</div>
)}
<div ref={endRef} />
<form className="composer" onSubmit={send}>
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} />
</button>
</form>
</div>
</AppShell>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
// H1 / H2 · Профиль и мои данные
export function Profile() {
const t = ru.profile;
const navigate = useNavigate();
const { session, signOut } = useSession();
const dialog = useDialog();
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
if (confirm && !d.open) d.showModal();
if (!confirm && d.open) d.close();
}, [confirm]);
if (!session) return null;
const anon = session.mode === "anonymous";
const revoke = async () => {
setConfirm(false);
if (await dialog.send({ type: "button", button: "consent:revoke" }, t.revoke)) navigate("/flow");
};
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
</header>
<section className="stack stack--sm">
<div className="row-card row-card--static">
<span className="avatar avatar--lg">
<Icon name={anon ? "lock" : "user"} />
</span>
<span className="row-card__text">
<span className="row-card__sub">{t.signInMode}</span>
<span className="row-card__title">
{anon ? t.anonymous : t.emias}
{session.displayName ? ` · ${session.displayName}` : ""}
</span>
</span>
</div>
{anon && (
<button
type="button"
className="btn btn--soft"
onClick={() => {
signOut();
navigate("/emias");
}}
>
{t.connectEmias}
</button>
)}
</section>
<section className="stack stack--sm">
<h2 className="h3">{t.data}</h2>
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
<span className="tile-icon">
<Icon name="message" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.messages}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card row-card--danger" onClick={() => setConfirm(true)}>
<span className="tile-icon tile-icon--red">
<Icon name="shield" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.revoke}</span>
</span>
<Icon name="chevronRight" />
</button>
{anon && (
<button type="button" className="row-card" onClick={() => signOut({ wipe: true })}>
<span className="tile-icon">
<Icon name="trash" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.wipe}</span>
<span className="row-card__sub">{t.wipeNote}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
<button type="button" className="row-card row-card--danger" onClick={() => signOut()}>
<span className="tile-icon tile-icon--red">
<Icon name="logout" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.logout}</span>
</span>
<Icon name="chevronRight" />
</button>
</section>
</div>
<dialog ref={ref} className="modal" aria-labelledby="revoke-title" onClose={() => setConfirm(false)}>
<div className="modal__body">
<div className="modal__head">
<h2 id="revoke-title" className="h2">
{t.revokeTitle}
</h2>
<button type="button" className="icon-btn" onClick={() => setConfirm(false)} aria-label={t.cancel}>
<Icon name="close" />
</button>
</div>
<p className="muted">{t.revokeText}</p>
<button type="button" className="btn btn--danger" onClick={revoke}>
{t.revokeConfirm}
</button>
<button type="button" className="btn btn--soft" onClick={() => setConfirm(false)}>
{t.cancel}
</button>
</div>
</dialog>
</AppShell>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useNavigate } from "react-router-dom";
import type { TodayDose } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { humanDate, useToday } from "./useToday";
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
return (
<button type="button" className="row-card" onClick={onOpen}>
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.visit}</span>
<span className="row-card__title">{humanDate(date)}</span>
</span>
<Icon name="chevronRight" />
</button>
);
}
// C1 / C2 · Сегодня
export function Today() {
const t = ru.today;
const navigate = useNavigate();
const { session } = useSession();
const dialog = useDialog();
const { plan, failed } = useToday();
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
const waiting = classify(dialog.pending);
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
const pendingDoses = plan?.doses.filter((d) => d.status === "pending") ?? [];
const takenDoses = plan?.doses.filter((d) => d.status === "taken") ?? [];
const allDone = plan !== null && pendingDoses.length === 0 && plan.checkin_done && !hasOpenStep;
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
const visit = plan?.next_visit ? <VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} /> : null;
return (
<AppShell aside={visit}>
<div className="stack">
<header className="today-head">
<div>
<h1 className="h1">{t.title}</h1>
{plan && <p className="muted">{humanDate(plan.today)}</p>}
</div>
<button type="button" className="icon-btn icon-btn--bell" onClick={() => navigate("/messages")} aria-label={ru.nav.messages}>
<Icon name="bell" />
{dialog.unread > 0 && <span className="dot" />}
</button>
</header>
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
{hasOpenStep && (
<button type="button" className="task task--hero" onClick={() => navigate("/flow")}>
<span className="task__icon">
<Icon name="message" />
</span>
<span className="task__text">
<span className="task__title">{t.newMessage}</span>
<span className="task__sub">{dialog.pending[0]?.text.split("\n")[0].slice(0, 80)}</span>
</span>
<span className="chip chip--teal-solid">{t.open}</span>
</button>
)}
{allDone ? (
<section className="alldone">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={36} />
</span>
<h2 className="h2 center">{t.allDoneTitle}</h2>
<p className="muted center">{nextDose ? t.allDoneNext(`завтра в ${nextDose}`) : t.allDoneNoMeds}</p>
{plan && plan.prescriptions.length > 0 && (
<button type="button" className="btn btn--primary" onClick={() => navigate("/meds")}>
{t.viewMeds}
<Icon name="chevronRight" size={20} />
</button>
)}
</section>
) : (
<div className="tasks">
{pendingDoses.map((d) => (
<button
key={d.id}
type="button"
className={d.time <= now && !hasOpenStep ? "task task--hero" : "task"}
onClick={() => openDose(d)}
>
<span className="task__icon task__icon--teal">
<Icon name="pill" />
</span>
<span className="task__text">
<span className="task__title">{t.takeMed}</span>
<span className="task__sub">{d.time}</span>
</span>
{d.time <= now && <span className="chip chip--teal-solid">{t.now}</span>}
<Icon name="chevronRight" />
</button>
))}
{plan && !plan.checkin_done && (
<button type="button" className="task" onClick={() => go("menu:checkin", t.checkin)} disabled={dialog.busy}>
<span className="task__icon task__icon--teal">
<Icon name="smile" />
</span>
<span className="task__text">
<span className="task__title">{t.checkin}</span>
<span className="task__sub">{t.checkinHint}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
</div>
)}
{!allDone && (takenDoses.length > 0 || plan?.checkin_done) && (
<section className="stack stack--sm">
<h2 className="h3">{t.done}</h2>
{takenDoses.map((d) => (
<div key={d.id} className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span>
<span className="done-row__title">{t.medTaken}</span>
<span className="done-row__sub">{t.medTakenAt(d.time)}</span>
</span>
</div>
))}
{plan?.checkin_done && (
<div className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span className="done-row__title">{t.checkinDone}</span>
</div>
)}
</section>
)}
{plan?.next_visit && (
<div className="only-phone">
<VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} />
</div>
)}
{session?.mode === "anonymous" || (plan && !plan.has_doctor) ? (
<Notice>{t.noDoctor}</Notice>
) : (
<div className="quick">
<button type="button" className="quick__btn" onClick={() => go("menu:med_question", t.medQuestion)} disabled={dialog.busy}>
<Icon name="question" />
{t.medQuestion}
</button>
<button type="button" className="quick__btn" onClick={() => go("menu:contact_doctor", t.messageDoctor)} disabled={dialog.busy}>
<Icon name="message" />
{t.messageDoctor}
</button>
</div>
)}
</div>
</AppShell>
);
}
+36 -128
View File
@@ -4,142 +4,50 @@ 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>
);
}
// A1 · Выбор способа входа
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>
<AppShell signedIn={false}>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<section className="signin" aria-labelledby="choice-title">
<h2 id="choice-title" className="signin__title">
{t.choose}
</h2>
<div className="entry-grid">
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
<Icon name="docPlus" size={26} />
</span>
<span className="chip chip--teal">{ru.common.recommended}</span>
</span>
<span className="entry__title">
{t.emias.title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.emias.text}</span>
</button>
<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>
<button type="button" className="entry" onClick={() => navigate("/anonymous")}>
<span className="entry__top">
<span className="tile-icon">
<Icon name="lock" size={26} />
</span>
</span>
<span className="entry__title">
{t.anonymous.title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.anonymous.text}</span>
</button>
</div>
</main>
<p className="muted small">{t.disclaimer}</p>
</div>
</AppShell>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { TodayPlan } from "../api/types";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
/** The patient's plan for today; refreshed whenever the dialog moves (a dose answered, a check-in done). */
export function useToday(): { plan: TodayPlan | null; failed: boolean } {
const { session } = useSession();
const { pending } = useDialog();
const [plan, setPlan] = useState<TodayPlan | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!session) return;
let alive = true;
api
.today(session.token)
.then((p) => {
if (!alive) return;
setPlan(p);
setFailed(false);
})
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [session, pending]);
return { plan, failed };
}
const MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"];
const WEEKDAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
/** "12 марта, вторник" — from an ISO date, no timezone shift. */
export function humanDate(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return `${d} ${MONTHS[m - 1]}, ${WEEKDAYS[wd]}`;
}