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