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:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.vite/
|
||||
|
||||
+12
-1
@@ -30,9 +30,20 @@ src/theme/tokens.css цвета, шрифт, отступы — единств
|
||||
src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
|
||||
src/api/ типы-зеркала контрактов бэкенда и клиент
|
||||
src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain
|
||||
src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat
|
||||
src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts)
|
||||
src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4),
|
||||
«Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1
|
||||
```
|
||||
|
||||
### Как устроены экраны
|
||||
|
||||
Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его
|
||||
как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст,
|
||||
напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда.
|
||||
|
||||
Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю,
|
||||
следующий визит, время по демо-часам).
|
||||
|
||||
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
|
||||
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types";
|
||||
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types";
|
||||
|
||||
// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
|
||||
const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
|
||||
@@ -35,6 +35,7 @@ export const api = {
|
||||
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
|
||||
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
|
||||
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
|
||||
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
|
||||
sendEvent: async (token: string, event: DialogEvent) =>
|
||||
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
|
||||
outbox: async (token: string) =>
|
||||
|
||||
@@ -39,3 +39,30 @@ export interface DemoPersona {
|
||||
therapy_day: number;
|
||||
scenario: string | null;
|
||||
}
|
||||
|
||||
export type DoseStatus = "taken" | "missed" | "skipped" | "pending";
|
||||
|
||||
export interface TodayDose {
|
||||
id: number;
|
||||
drug: string;
|
||||
dose: string;
|
||||
time: string;
|
||||
status: DoseStatus;
|
||||
}
|
||||
|
||||
export interface TodayPrescription {
|
||||
drug: string;
|
||||
dose: string;
|
||||
times: string[];
|
||||
week: { day: string; status: "taken" | "missed" | "pending" | "none" }[];
|
||||
}
|
||||
|
||||
export interface TodayPlan {
|
||||
today: string;
|
||||
now: string;
|
||||
has_doctor: boolean;
|
||||
checkin_done: boolean;
|
||||
next_visit: string | null;
|
||||
doses: TodayDose[];
|
||||
prescriptions: TodayPrescription[];
|
||||
}
|
||||
|
||||
+159
-47
@@ -1,64 +1,176 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
|
||||
import { ru } from "../content/ru";
|
||||
import { HelpSheet } from "./HelpSheet";
|
||||
import { Icon, LogoMark } from "./Icon";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { HotlineCard, useHelp } from "./HelpSheet";
|
||||
import { Icon, LogoMark, type IconName } from "./Icon";
|
||||
|
||||
export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
/*
|
||||
* Layout from docs/mock (series 2).
|
||||
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
|
||||
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel)
|
||||
*/
|
||||
|
||||
const TABS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/today", label: ru.nav.today, icon: "home" },
|
||||
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
|
||||
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
|
||||
{ to: "/profile", label: ru.nav.profile, icon: "user" },
|
||||
];
|
||||
|
||||
/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */
|
||||
export function EmergencyBlock() {
|
||||
const { resources, open } = useHelp();
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="topbar__inner">
|
||||
<Link to="/" className="brand" aria-label={ru.product.name}>
|
||||
<LogoMark />
|
||||
<span className="brand__text">
|
||||
<span className="brand__name">{ru.product.name}</span>
|
||||
<span className="brand__tagline">{ru.product.tagline}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}>
|
||||
<Icon name="phone" size={18} />
|
||||
<span>{ru.help.button}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="sosblock" aria-label={ru.help.block}>
|
||||
<button type="button" className="sosblock__head" onClick={open}>
|
||||
<span className="sosblock__icon">
|
||||
<Icon name="phone" size={18} />
|
||||
</span>
|
||||
<span>{ru.help.block}</span>
|
||||
<Icon name="chevronRight" size={20} />
|
||||
</button>
|
||||
<div className="sosblock__lines">
|
||||
{resources.slice(0, 2).map((r) => (
|
||||
<HotlineCard key={r.phone} r={r} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{children}
|
||||
|
||||
{footer && (
|
||||
<footer className="footer">
|
||||
<div className="footer__inner">
|
||||
<p className="footer__line">
|
||||
<Icon name="lock" size={16} />
|
||||
<span>{ru.footer.law}</span>
|
||||
</p>
|
||||
<p className="footer__line footer__line--muted">{ru.footer.prototype}</p>
|
||||
</div>
|
||||
</footer>
|
||||
function Sidebar({ signedIn }: { signedIn: boolean }) {
|
||||
const { open } = useHelp();
|
||||
const { unread } = useDialog();
|
||||
return (
|
||||
<nav className="sidebar" aria-label="Разделы">
|
||||
<Link to={signedIn ? "/today" : "/"} className="sidebar__brand">
|
||||
<LogoMark />
|
||||
<span>
|
||||
<span className="sidebar__name">{ru.product.name}</span>
|
||||
<span className="sidebar__tag">{ru.product.tagline}</span>
|
||||
</span>
|
||||
</Link>
|
||||
{signedIn ? (
|
||||
<ul className="sidebar__list">
|
||||
{TABS.map((t) => (
|
||||
<li key={t.to}>
|
||||
<NavLink to={t.to} className="sidebar__link">
|
||||
<Icon name={t.icon} />
|
||||
{t.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NavLink to="/messages" className="sidebar__link">
|
||||
<Icon name="message" />
|
||||
{ru.nav.messages}
|
||||
{unread > 0 && <span className="dot" aria-label="новое" />}
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="sidebar__list">
|
||||
<li>
|
||||
<NavLink to="/" end className="sidebar__link">
|
||||
<Icon name="home" />
|
||||
{ru.nav.signIn}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" className="sidebar__link" onClick={open}>
|
||||
<Icon name="question" />
|
||||
{ru.nav.help}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
<HelpSheet open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
function BottomNav() {
|
||||
return (
|
||||
<nav className="tabbar" aria-label="Разделы">
|
||||
{TABS.map((t) => (
|
||||
<NavLink key={t.to} to={t.to} className="tabbar__item">
|
||||
<Icon name={t.icon} />
|
||||
<span>{t.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ aside }: { aside?: ReactNode }) {
|
||||
const { resources, open } = useHelp();
|
||||
return (
|
||||
<aside className="rail">
|
||||
{aside}
|
||||
<section className="railsos" aria-label={ru.help.block}>
|
||||
<button type="button" className="railsos__head" onClick={open}>
|
||||
<Icon name="phone" size={18} />
|
||||
<span>{ru.help.block}</span>
|
||||
</button>
|
||||
{resources.map((r) => (
|
||||
<HotlineCard key={r.phone} r={r} size="sm" />
|
||||
))}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
signedIn = true,
|
||||
aside,
|
||||
focus = false,
|
||||
sos = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
signedIn?: boolean;
|
||||
aside?: ReactNode;
|
||||
/** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */
|
||||
focus?: boolean;
|
||||
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
|
||||
sos?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
|
||||
<Sidebar signedIn={signedIn} />
|
||||
<div className="app__main">
|
||||
<main className="screen" id="main">
|
||||
{children}
|
||||
{sos && <EmergencyBlock />}
|
||||
</main>
|
||||
<Rail aside={aside} />
|
||||
</div>
|
||||
{signedIn && !focus && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackLink({ to, children }: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link to={to} className="back-link">
|
||||
<Icon name="arrowLeft" size={18} />
|
||||
<span>{children}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
|
||||
export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
|
||||
const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
|
||||
return (
|
||||
<div className={`notice notice--${tone}`} role="note">
|
||||
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
|
||||
<p>{children}</p>
|
||||
<Icon name={icon} />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
{onBack && (
|
||||
<button type="button" className="icon-btn" onClick={onBack} aria-label={ru.common.back}>
|
||||
<Icon name="arrowLeft" />
|
||||
</button>
|
||||
)}
|
||||
{title && <span className="topbar__title">{title}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { CrisisResource } from "../api/types";
|
||||
import { ru } from "../content/ru";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
// 112 is shown even when the API is unreachable: the help sheet must never be empty.
|
||||
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }];
|
||||
// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»).
|
||||
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }];
|
||||
|
||||
const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
||||
export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
||||
|
||||
export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
interface HelpApi {
|
||||
resources: CrisisResource[];
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<HelpApi>({ resources: FALLBACK, open: () => undefined });
|
||||
export const useHelp = () => useContext(Ctx);
|
||||
|
||||
/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */
|
||||
export function HelpProvider({ children }: { children: ReactNode }) {
|
||||
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => setOpen(true), []);
|
||||
return (
|
||||
<Ctx.Provider value={{ resources, open }}>
|
||||
{children}
|
||||
<HelpSheet open={isOpen} onClose={() => setOpen(false)} resources={resources} />
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) {
|
||||
const emergency = r.phone.replace(/\D/g, "").length <= 3;
|
||||
return (
|
||||
<a className={`hotline hotline--${size}`} href={telHref(r.phone)}>
|
||||
<span className="hotline__icon">
|
||||
<Icon name={emergency ? "phone" : "headset"} size={size === "lg" ? 26 : 20} />
|
||||
</span>
|
||||
<span className="hotline__text">
|
||||
{emergency ? (
|
||||
<>
|
||||
<span className="hotline__phone">{r.phone}</span>
|
||||
<span className="hotline__name">{r.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="hotline__name hotline__name--strong">{r.name}</span>
|
||||
<span className="hotline__phone hotline__phone--sm">{r.phone}</span>
|
||||
</>
|
||||
)}
|
||||
{r.note && size === "lg" && <span className="hotline__note">{r.note}</span>}
|
||||
</span>
|
||||
{size === "lg" && <Icon name="chevronRight" size={20} />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
@@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
|
||||
onClick={(e) => e.target === ref.current && onClose()}
|
||||
>
|
||||
<div className="sheet__body">
|
||||
<div className="sheet__head">
|
||||
<h2 id="help-title" className="sheet__title">
|
||||
{ru.help.title}
|
||||
</h2>
|
||||
<div className="sheet__bar">
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
<h2 id="help-title" className="sheet__title">
|
||||
{ru.help.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="sheet__lead">{ru.help.lead}</p>
|
||||
<ul className="hotlines">
|
||||
<div className="sos">
|
||||
<span className="sos__ring">
|
||||
<span className="sos__btn">
|
||||
<Icon name="phone" size={40} />
|
||||
</span>
|
||||
</span>
|
||||
<p className="sos__lead">{ru.help.lead}</p>
|
||||
</div>
|
||||
<div className="hotlines">
|
||||
{resources.map((r) => (
|
||||
<li key={r.phone}>
|
||||
<a className="hotline" href={telHref(r.phone)}>
|
||||
<span className="hotline__icon">
|
||||
<Icon name="phone" size={22} />
|
||||
</span>
|
||||
<span className="hotline__text">
|
||||
<span className="hotline__phone">{r.phone}</span>
|
||||
<span className="hotline__name">{r.name}</span>
|
||||
{r.note && <span className="hotline__note">{r.note}</span>}
|
||||
</span>
|
||||
<span className="hotline__call">{ru.help.call}</span>
|
||||
</a>
|
||||
</li>
|
||||
<HotlineCard key={r.phone} r={r} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="notice notice--info">
|
||||
<Icon name="info" />
|
||||
<span>{ru.help.alwaysNote}</span>
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
+104
-26
@@ -1,14 +1,37 @@
|
||||
// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers.
|
||||
// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers.
|
||||
|
||||
const paths = {
|
||||
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
||||
minus: <path d="M6 12h12" />,
|
||||
phone: (
|
||||
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
||||
home: <path d="M4 10.5L12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-6h-5v6H5a1 1 0 0 1-1-1v-8.5z" />,
|
||||
pill: (
|
||||
<>
|
||||
<rect x="3" y="8.5" width="18" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
||||
<path d="M9.5 9.5l5 5" />
|
||||
</>
|
||||
),
|
||||
doctor: (
|
||||
<>
|
||||
<circle cx="12" cy="7.5" r="3.5" />
|
||||
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
||||
<path d="M12 15.5v3m-1.5-1.5h3" />
|
||||
</>
|
||||
),
|
||||
user: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4.5 20.5c.8-3.7 3.8-6 7.5-6s6.7 2.3 7.5 6" />
|
||||
</>
|
||||
),
|
||||
bell: (
|
||||
<>
|
||||
<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 2h-15L6 16.5z" />
|
||||
<path d="M10 20.5a2 2 0 0 0 4 0" />
|
||||
</>
|
||||
),
|
||||
chevronRight: <path d="M9.5 6l6 6-6 6" />,
|
||||
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
|
||||
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />,
|
||||
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
||||
close: <path d="M6 6l12 12M18 6L6 18" />,
|
||||
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
||||
x: <path d="M7 7l10 10M17 7L7 17" />,
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -21,33 +44,88 @@ const paths = {
|
||||
<path d="M12 10v4.5m0 2.5v.5" />
|
||||
</>
|
||||
),
|
||||
phone: (
|
||||
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
||||
),
|
||||
headset: (
|
||||
<>
|
||||
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
|
||||
<rect x="3" y="13.5" width="4" height="6" rx="1.5" />
|
||||
<rect x="17" y="13.5" width="4" height="6" rx="1.5" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
|
||||
<path d="M3.5 9.5h17M8 3v4m8-4v4M8 13h1m3 0h1m3 0h1M8 16.5h1m3 0h1" />
|
||||
</>
|
||||
),
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3 2" />
|
||||
</>
|
||||
),
|
||||
pin: (
|
||||
<>
|
||||
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
|
||||
<circle cx="12" cy="10" r="2.5" />
|
||||
</>
|
||||
),
|
||||
smile: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M8.5 14c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2M9 9.8v.4m6-.4v.4" />
|
||||
</>
|
||||
),
|
||||
doc: (
|
||||
<>
|
||||
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
|
||||
<path d="M14 3.5V8h4M9 12h6m-6 3.5h6" />
|
||||
</>
|
||||
),
|
||||
docPlus: (
|
||||
<>
|
||||
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
|
||||
<path d="M14 3.5V8h4M12 11v6m-3-3h6" />
|
||||
</>
|
||||
),
|
||||
shield: (
|
||||
<>
|
||||
<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6L12 3z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
|
||||
<rect x="5" y="10.5" width="14" height="10" rx="2" />
|
||||
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
|
||||
</>
|
||||
),
|
||||
pill: (
|
||||
<>
|
||||
<rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
||||
<path d="M9.5 9.5l5 5" />
|
||||
</>
|
||||
),
|
||||
pulse: <path d="M3 12h4l2-5 4 10 2-5h6" />,
|
||||
doctor: (
|
||||
<>
|
||||
<circle cx="12" cy="7.5" r="3.5" />
|
||||
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
||||
<path d="M12 15.5v3m-1.5-1.5h3" />
|
||||
</>
|
||||
),
|
||||
close: <path d="M6 6l12 12M18 6L6 18" />,
|
||||
edit: <path d="M5 19l1-4L15.5 5.5a2 2 0 0 1 3 3L9 18l-4 1zM14 7l3 3" />,
|
||||
trash: <path d="M5 7h14M10 7V4.5h4V7m-7 0l1 12.5h8L17 7M10.5 11v5m3-5v5" />,
|
||||
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
|
||||
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
||||
heart: <path d="M12 19.5s-7.5-4.4-7.5-10a4.2 4.2 0 0 1 7.5-2.6 4.2 4.2 0 0 1 7.5 2.6c0 5.6-7.5 10-7.5 10z" />,
|
||||
lungs: (
|
||||
<>
|
||||
<path d="M12 4v8m0 0c-1 1.2-2.3 2-3.5 2M12 12c1 1.2 2.3 2 3.5 2" />
|
||||
<path d="M8.5 7.5C6 8 4 11.5 4 16c0 2 1 3.5 3 3.5s3-1.5 3-4V9M15.5 7.5C18 8 20 11.5 20 16c0 2-1 3.5-3 3.5s-3-1.5-3-4V9" />
|
||||
</>
|
||||
),
|
||||
question: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M9.6 9.5a2.4 2.4 0 0 1 4.7.7c0 1.6-2.3 2-2.3 3.6m0 2.7v.3" />
|
||||
</>
|
||||
),
|
||||
message: <path d="M4 5.5h16v11H9.5L5 20v-3.5H4z" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof paths;
|
||||
|
||||
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
|
||||
export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -55,7 +133,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role={label ? "img" : undefined}
|
||||
@@ -71,7 +149,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
|
||||
export function LogoMark({ size = 32 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
||||
<rect width="32" height="32" rx="6" fill="var(--c-primary)" />
|
||||
<rect width="32" height="32" rx="9" fill="var(--c-teal)" />
|
||||
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
+128
-63
@@ -1,100 +1,165 @@
|
||||
/*
|
||||
* All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/
|
||||
* for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here.
|
||||
* All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales,
|
||||
* reminders — comes from the engine (config/texts/) and is NOT duplicated here.
|
||||
*/
|
||||
export const ru = {
|
||||
product: {
|
||||
name: "Сопровождение пациента",
|
||||
tagline: "между визитами к врачу",
|
||||
prototype: "Прототип · синтетические данные",
|
||||
},
|
||||
nav: {
|
||||
today: "Сегодня",
|
||||
meds: "Лекарства",
|
||||
doctor: "Врач",
|
||||
profile: "Профиль",
|
||||
messages: "Сообщения",
|
||||
signIn: "Вход",
|
||||
help: "Помощь",
|
||||
},
|
||||
help: {
|
||||
button: "Нужна помощь",
|
||||
title: "Если вам сейчас очень тяжело",
|
||||
lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:",
|
||||
block: "Экстренная помощь",
|
||||
title: "Экстренная помощь",
|
||||
lead: "Если вам нужна срочная помощь, позвоните сразу.",
|
||||
call: "Позвонить",
|
||||
close: "Закрыть",
|
||||
fallbackName: "Экстренные службы",
|
||||
fallbackNote: "если есть угроза жизни прямо сейчас",
|
||||
alwaysNote: "Вы не одни. Помощь доступна круглосуточно.",
|
||||
fallbackName: "Единый номер экстренных служб",
|
||||
fallbackNote: "",
|
||||
},
|
||||
common: {
|
||||
back: "Назад",
|
||||
next: "Далее",
|
||||
skip: "Пропустить",
|
||||
send: "Отправить",
|
||||
retry: "Повторить",
|
||||
toToday: "Вернуться на сегодня",
|
||||
recommended: "Рекомендуем",
|
||||
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
|
||||
},
|
||||
welcome: {
|
||||
title: "Лечение продолжается и между приёмами",
|
||||
lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.",
|
||||
day: {
|
||||
caption: "Обычный день с сервисом",
|
||||
meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" },
|
||||
mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" },
|
||||
doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" },
|
||||
},
|
||||
choose: "Как вы хотите войти?",
|
||||
title: "Как вы хотите войти в приложение?",
|
||||
lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
|
||||
emias: {
|
||||
title: "Через ЕМИАС",
|
||||
badge: "Рекомендуем",
|
||||
facts: [
|
||||
{ ok: true, text: "Назначения врача подгружаются автоматически" },
|
||||
{ ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" },
|
||||
{ ok: true, text: "История доступна с любого устройства" },
|
||||
],
|
||||
action: "Войти через ЕМИАС",
|
||||
text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
|
||||
},
|
||||
anonymous: {
|
||||
title: "Анонимно",
|
||||
facts: [
|
||||
{ ok: true, text: "Не нужны имя, полис и телефон" },
|
||||
{ ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" },
|
||||
{ ok: false, text: "Врач не видит ваши ответы и не получает сигналов" },
|
||||
{ ok: false, text: "История доступна только на этом устройстве" },
|
||||
],
|
||||
action: "Продолжить анонимно",
|
||||
text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
|
||||
},
|
||||
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
|
||||
},
|
||||
emias: {
|
||||
back: "Способы входа",
|
||||
title: "Вход через ЕМИАС",
|
||||
demoNotice:
|
||||
"Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.",
|
||||
codeLabel: "Код подключения от врача",
|
||||
codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.",
|
||||
title: "Введите код от врача",
|
||||
lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.",
|
||||
demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.",
|
||||
codeLabel: "Код подключения",
|
||||
codePlaceholder: "Код из памятки",
|
||||
submit: "Войти",
|
||||
submit: "Продолжить",
|
||||
submitting: "Проверяем код…",
|
||||
codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.",
|
||||
networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.",
|
||||
codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.",
|
||||
or: "или",
|
||||
demoTitle: "Демо-пациенты",
|
||||
demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.",
|
||||
demoLead: "Нет кода? Войдите под синтетической учётной записью.",
|
||||
demoDay: (day: number) => `${day}-й день терапии`,
|
||||
demoAction: "Войти",
|
||||
demoEmpty: "Демо-пациенты недоступны.",
|
||||
},
|
||||
anonymous: {
|
||||
back: "Способы входа",
|
||||
title: "Анонимный вход",
|
||||
lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.",
|
||||
points: [
|
||||
"Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.",
|
||||
"Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.",
|
||||
"Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.",
|
||||
],
|
||||
warning:
|
||||
"История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.",
|
||||
submit: "Начать анонимно",
|
||||
submitting: "Создаём анонимный профиль…",
|
||||
lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
|
||||
yesTitle: "Что будет доступно:",
|
||||
yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
|
||||
noTitle: "Чего не будет:",
|
||||
no: ["Врач не увидит ответы", "История только на этом устройстве"],
|
||||
warning: "После выхода историю восстановить нельзя.",
|
||||
submit: "Продолжить анонимно",
|
||||
submitting: "Создаём профиль…",
|
||||
},
|
||||
chat: {
|
||||
modeEmias: "ЕМИАС",
|
||||
modeAnonymous: "Анонимно",
|
||||
today: {
|
||||
title: "Сегодня",
|
||||
takeMed: "Принять лекарство",
|
||||
now: "Сейчас",
|
||||
checkin: "Отметка самочувствия",
|
||||
checkinHint: "меньше минуты",
|
||||
done: "Уже выполнено",
|
||||
medTaken: "Лекарство",
|
||||
medTakenAt: (time: string) => `принято, ${time}`,
|
||||
checkinDone: "Самочувствие отмечено",
|
||||
allDoneTitle: "На сегодня всё сделано",
|
||||
allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`,
|
||||
allDoneNoMeds: "Следующая отметка самочувствия — завтра",
|
||||
viewMeds: "Посмотреть лекарства",
|
||||
visit: "Следующий визит к врачу",
|
||||
medQuestion: "Вопрос о лекарстве",
|
||||
messageDoctor: "Сообщение врачу",
|
||||
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.",
|
||||
newMessage: "Новое сообщение",
|
||||
open: "Открыть",
|
||||
},
|
||||
meds: {
|
||||
title: "Моя схема приёма",
|
||||
lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.",
|
||||
byDoctor: "По назначению врача",
|
||||
tablet: "1 приём",
|
||||
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
|
||||
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
|
||||
empty: "Назначения появятся здесь после входа через ЕМИАС.",
|
||||
question: "Вопрос о лекарстве",
|
||||
reminderTitle: "Время принять препарат",
|
||||
reminderLead: "По назначению врача",
|
||||
taken: "Принял(а)",
|
||||
skip: "Пропущу",
|
||||
sideEffects: "Есть побочные эффекты",
|
||||
},
|
||||
doctor: {
|
||||
title: "Врач",
|
||||
visit: "Следующий визит",
|
||||
visitNone: "Дата следующего визита пока не назначена",
|
||||
attending: "Ваш лечащий врач",
|
||||
write: "Написать врачу",
|
||||
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.",
|
||||
earlier: "Записаться к врачу раньше",
|
||||
plan: "Мой план безопасности",
|
||||
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.",
|
||||
},
|
||||
profile: {
|
||||
title: "Профиль",
|
||||
signInMode: "Способ входа",
|
||||
emias: "ЕМИАС",
|
||||
anonymous: "Анонимно",
|
||||
connectEmias: "Подключить ЕМИАС",
|
||||
data: "Мои данные",
|
||||
revoke: "Отозвать согласие",
|
||||
revokeTitle: "Отозвать согласие?",
|
||||
revokeText: "Ваши ответы больше не будут обрабатываться в приложении.",
|
||||
revokeConfirm: "Да, отозвать",
|
||||
cancel: "Отмена",
|
||||
wipe: "Удалить данные с устройства",
|
||||
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
|
||||
logout: "Выйти",
|
||||
logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.",
|
||||
messages: "Сообщения",
|
||||
},
|
||||
flow: {
|
||||
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
|
||||
period: "За последний месяц",
|
||||
textPlaceholder: "Напишите несколько слов",
|
||||
sending: "Отправляем…",
|
||||
doneTitle: "Готово",
|
||||
doneLead: "Врач увидит отметку.",
|
||||
breathe: "Вдох на 4 — выдох на 6",
|
||||
breatheIn: "Вдох",
|
||||
breatheOut: "Выдох",
|
||||
openPlan: "Открыть мой план безопасности",
|
||||
supportTitle: "Вы не одни",
|
||||
exit: "К главной",
|
||||
planTitle: "Мой план безопасности",
|
||||
consentTitle: "Согласие на обработку данных о здоровье",
|
||||
},
|
||||
messages: {
|
||||
title: "Сообщения",
|
||||
inputLabel: "Сообщение",
|
||||
inputPlaceholder: "Напишите сообщение",
|
||||
send: "Отправить",
|
||||
loading: "Подключаемся…",
|
||||
sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.",
|
||||
you: "Вы",
|
||||
assistant: "Помощник",
|
||||
crisisLabel: "Важно",
|
||||
empty: "Здесь будут сообщения помощника.",
|
||||
},
|
||||
footer: {
|
||||
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { DialogEvent, Reply } from "../api/types";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
|
||||
|
||||
/*
|
||||
* One conversation with the dialog engine per signed-in patient, shared by every screen:
|
||||
* focus screens render `pending` (the latest unanswered batch), «Сообщения» renders `entries`.
|
||||
* The engine owns the logic (consent, onboarding, scales, check-ins, meds, crisis) — exactly as in Telegram.
|
||||
*/
|
||||
|
||||
const OUTBOX_POLL_MS = 6000;
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
|
||||
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
|
||||
|
||||
interface DialogApi extends DialogSnapshot {
|
||||
busy: boolean;
|
||||
error: boolean;
|
||||
started: boolean;
|
||||
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
|
||||
send: (event: DialogEvent, echo?: string) => Promise<boolean>;
|
||||
start: () => Promise<boolean>;
|
||||
markRead: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<DialogApi | null>(null);
|
||||
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const { session } = useSession();
|
||||
const token = session?.token ?? null;
|
||||
const [owner, setOwner] = useState(token);
|
||||
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
|
||||
// Switching accounts: reset to that token's snapshot during render, so it is never saved under another token.
|
||||
if (owner !== token) {
|
||||
setOwner(token);
|
||||
setSnap(token ? sessionStore.loadDialog(token) : EMPTY);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token && owner === token) sessionStore.saveDialog(token, snap);
|
||||
}, [token, owner, snap]);
|
||||
|
||||
const send = useCallback(
|
||||
async (event: DialogEvent, echo?: string) => {
|
||||
const t = tokenRef.current;
|
||||
if (!t) return false;
|
||||
setBusy(true);
|
||||
setError(false);
|
||||
try {
|
||||
const replies = await api.sendEvent(t, event);
|
||||
setSnap((prev) => ({
|
||||
entries: [
|
||||
...prev.entries,
|
||||
...(echo ? [{ id: newId(), from: "patient" as const, text: echo }] : []),
|
||||
...asEntries(replies),
|
||||
],
|
||||
pending: replies,
|
||||
unread: 0,
|
||||
}));
|
||||
return true;
|
||||
} catch {
|
||||
setError(true);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const start = useCallback(() => send({ type: "start" }), [send]);
|
||||
const markRead = useCallback(() => setSnap((prev) => (prev.unread ? { ...prev, unread: 0 } : prev)), []);
|
||||
|
||||
// System-initiated messages (reminders, check-ins, scales) arrive through the outbox.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const timer = window.setInterval(() => {
|
||||
api
|
||||
.outbox(token)
|
||||
.then((replies) => {
|
||||
if (!replies.length || tokenRef.current !== token) return;
|
||||
setSnap((prev) => ({
|
||||
entries: [...prev.entries, ...asEntries(replies)],
|
||||
pending: replies,
|
||||
unread: prev.unread + replies.length,
|
||||
}));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, OUTBOX_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo<DialogApi>(
|
||||
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
|
||||
[snap, busy, error, send, start, markRead],
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useDialog(): DialogApi {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error("useDialog outside DialogProvider");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { DialogButton, Reply } from "../api/types";
|
||||
|
||||
/*
|
||||
* Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is
|
||||
* asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen.
|
||||
*/
|
||||
|
||||
export type Step =
|
||||
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
|
||||
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
|
||||
| { kind: "crisis"; text: string; buttons: DialogButton[] }
|
||||
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
|
||||
| { kind: "number"; lead: string[]; title: string; options: DialogButton[] }
|
||||
| { kind: "actions"; lead: string[]; title: string; options: DialogButton[] }
|
||||
| { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress }
|
||||
| { kind: "text"; lead: string[]; title: string; skip?: DialogButton };
|
||||
|
||||
export interface Progress {
|
||||
n: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own.
|
||||
const MENU_PROMPT = "Чем могу помочь?";
|
||||
// config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}"
|
||||
const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/;
|
||||
|
||||
const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:"));
|
||||
const flat = (r: Reply) => r.buttons.flat();
|
||||
const paragraphs = (text: string) =>
|
||||
text
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** Splits a reply into a title (the actual question) and lead paragraphs above it. */
|
||||
function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } {
|
||||
const parts = texts.flatMap(paragraphs);
|
||||
const last = parts.pop() ?? "";
|
||||
const m = SCALE_ITEM.exec(last);
|
||||
if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } };
|
||||
// A long last paragraph is body text, not a heading.
|
||||
if (last.length > 220) return { lead: [...parts, last], title: "" };
|
||||
return { lead: parts, title: last };
|
||||
}
|
||||
|
||||
export function classify(batch: Reply[]): Step | null {
|
||||
if (batch.length === 0) return null;
|
||||
|
||||
const crisis = batch.find((r) => r.kind === "crisis");
|
||||
if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) };
|
||||
|
||||
if (batch.some(isMenu)) {
|
||||
const notes = batch
|
||||
.filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT)
|
||||
.map((r) => r.text.trim())
|
||||
.filter((t) => t && t !== MENU_PROMPT);
|
||||
return { kind: "idle", notes };
|
||||
}
|
||||
|
||||
const plan = batch.find((r) => r.kind === "safety_plan");
|
||||
if (plan) {
|
||||
const [title, ...rest] = plan.text.split("\n").map((l) => l.trim());
|
||||
const lead = batch.filter((r) => r !== plan).map((r) => r.text);
|
||||
return {
|
||||
kind: "plan",
|
||||
lead,
|
||||
title,
|
||||
lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")),
|
||||
buttons: batch.flatMap(flat),
|
||||
};
|
||||
}
|
||||
|
||||
// The question is the last reply with buttons; everything before it is context.
|
||||
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
|
||||
const texts = batch.map((r) => r.text);
|
||||
if (qIndex === -1) {
|
||||
const { lead, title } = titled(texts);
|
||||
return { kind: "text", lead, title };
|
||||
}
|
||||
const options = flat(batch[qIndex]);
|
||||
const head = titled(texts.slice(0, qIndex + 1));
|
||||
const tail = texts.slice(qIndex + 1); // rare: info after the question
|
||||
const lead = [...head.lead, ...tail];
|
||||
|
||||
if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options };
|
||||
if (options.every((b) => b.data.endsWith(":skip")))
|
||||
return { kind: "text", lead, title: head.title, skip: options[0] };
|
||||
if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim())))
|
||||
return { kind: "number", lead, title: head.title, options };
|
||||
if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data)))
|
||||
return { kind: "actions", lead, title: head.title, options };
|
||||
return { kind: "choice", lead, title: head.title, options, progress: head.progress };
|
||||
}
|
||||
|
||||
/** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */
|
||||
export function lines(text: string): { list: boolean; text: string }[] {
|
||||
const out: { list: boolean; text: string }[] = [];
|
||||
for (const raw of text.split("\n")) {
|
||||
const l = raw.trim();
|
||||
if (!l) continue;
|
||||
if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") });
|
||||
else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text))
|
||||
out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` };
|
||||
else out.push({ list: false, text: l });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+30
-7
@@ -1,28 +1,47 @@
|
||||
import "@fontsource/golos-text/400.css";
|
||||
import "@fontsource/golos-text/500.css";
|
||||
import "@fontsource/golos-text/600.css";
|
||||
import "@fontsource/golos-text/700.css";
|
||||
import "./theme/tokens.css";
|
||||
import "./theme/global.css";
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { HelpProvider } from "./components/HelpSheet";
|
||||
import { DialogProvider } from "./dialog/DialogContext";
|
||||
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
|
||||
import { Chat } from "./screens/Chat";
|
||||
import { Doctor } from "./screens/Doctor";
|
||||
import { EmiasSignIn } from "./screens/EmiasSignIn";
|
||||
import { Flow } from "./screens/Flow";
|
||||
import { DoseReminder, Meds } from "./screens/Meds";
|
||||
import { Messages } from "./screens/Messages";
|
||||
import { Profile } from "./screens/Profile";
|
||||
import { Today } from "./screens/Today";
|
||||
import { Welcome } from "./screens/Welcome";
|
||||
import { SessionProvider, useSession } from "./session/SessionContext";
|
||||
|
||||
function Private({ children }: { children: ReactNode }) {
|
||||
const { session } = useSession();
|
||||
return session ? <>{children}</> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
|
||||
function AppRoutes() {
|
||||
const { session } = useSession();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={session ? <Navigate to="/chat" replace /> : <Welcome />} />
|
||||
<Route path="/" element={session ? <Navigate to="/today" replace /> : <Welcome />} />
|
||||
<Route path="/emias" element={<EmiasSignIn />} />
|
||||
<Route path="/anonymous" element={<AnonymousSignIn />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/flow" element={<Private><Flow /></Private>} />
|
||||
<Route path="/today" element={<Private><Today /></Private>} />
|
||||
<Route path="/dose/:id" element={<Private><DoseReminder /></Private>} />
|
||||
<Route path="/meds" element={<Private><Meds /></Private>} />
|
||||
<Route path="/doctor" element={<Private><Doctor /></Private>} />
|
||||
<Route path="/profile" element={<Private><Profile /></Private>} />
|
||||
<Route path="/messages" element={<Private><Messages /></Private>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -31,9 +50,13 @@ function AppRoutes() {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<SessionProvider>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
<DialogProvider>
|
||||
<HelpProvider>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
</HelpProvider>
|
||||
</DialogProvider>
|
||||
</SessionProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { sessionStore, type Session } from "./store";
|
||||
interface SessionApi {
|
||||
session: Session | null;
|
||||
signIn: (auth: AuthResult) => void;
|
||||
signOut: () => void;
|
||||
/** Leaves the account. Anonymous history is always wiped: it cannot be restored anyway. */
|
||||
signOut: (opts?: { wipe?: boolean }) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<SessionApi | null>(null);
|
||||
@@ -20,9 +21,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
const signOut = useCallback((opts?: { wipe?: boolean }) => {
|
||||
setSession((current) => {
|
||||
sessionStore.clear(current);
|
||||
sessionStore.clear(current, opts?.wipe ?? current?.mode === "anonymous");
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -12,9 +12,16 @@ export interface Session {
|
||||
}
|
||||
|
||||
export type ChatEntry =
|
||||
| { id: string; from: "assistant"; reply: Reply; answered: boolean }
|
||||
| { id: string; from: "assistant"; reply: Reply }
|
||||
| { id: string; from: "patient"; text: string };
|
||||
|
||||
/** Dialog snapshot per token: full history + the latest unanswered batch of engine replies. */
|
||||
export interface DialogSnapshot {
|
||||
entries: ChatEntry[];
|
||||
pending: Reply[];
|
||||
unread: number;
|
||||
}
|
||||
|
||||
interface KeyValue {
|
||||
get(key: string): string | null;
|
||||
set(key: string, value: string): void;
|
||||
@@ -46,7 +53,8 @@ const browserStorage: KeyValue = {
|
||||
};
|
||||
|
||||
const SESSION_KEY = "ptsr.session.v1";
|
||||
const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
|
||||
const dialogKey = (token: string) => `ptsr.dialog.v2.${token}`;
|
||||
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
|
||||
|
||||
function read<T>(storage: KeyValue, key: string): T | null {
|
||||
const raw = storage.get(key);
|
||||
@@ -62,12 +70,12 @@ export function createSessionStore(storage: KeyValue = browserStorage) {
|
||||
return {
|
||||
load: () => read<Session>(storage, SESSION_KEY),
|
||||
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
|
||||
clear: (s: Session | null) => {
|
||||
clear: (s: Session | null, wipeDialog = true) => {
|
||||
storage.remove(SESSION_KEY);
|
||||
if (s) storage.remove(chatKey(s.token));
|
||||
if (s && wipeDialog) storage.remove(dialogKey(s.token));
|
||||
},
|
||||
loadChat: (token: string) => read<ChatEntry[]>(storage, chatKey(token)) ?? [],
|
||||
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
|
||||
loadDialog: (token: string): DialogSnapshot => ({ ...EMPTY, ...read<DialogSnapshot>(storage, dialogKey(token)) }),
|
||||
saveDialog: (token: string, snap: DialogSnapshot) => storage.set(dialogKey(token), JSON.stringify(snap)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1522
-1033
File diff suppressed because it is too large
Load Diff
+46
-55
@@ -1,64 +1,57 @@
|
||||
/*
|
||||
* Design tokens — the single source of colours, type and spacing.
|
||||
* When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a
|
||||
* theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
|
||||
* Design tokens — single source of colours, type and spacing (docs/mock, series 2).
|
||||
* Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object.
|
||||
*/
|
||||
:root {
|
||||
/* Base: cool ink on cool white. */
|
||||
--c-ink: #13233a;
|
||||
--c-text: #13233a;
|
||||
--c-text-2: #46566b;
|
||||
--c-text-3: #6b7a8e;
|
||||
--c-page: #f4f7fb;
|
||||
/* Ink & surfaces */
|
||||
--c-navy: #0f2747; /* primary buttons, headings, desktop sidebar */
|
||||
--c-navy-hover: #0a1d37;
|
||||
--c-navy-soft: #e8eef6;
|
||||
--c-text: #13294b;
|
||||
--c-text-2: #4d5d73;
|
||||
--c-text-3: #7a889b;
|
||||
--c-page: #ffffff;
|
||||
--c-page-desk: #f4f7fb; /* desktop canvas behind white panels */
|
||||
--c-surface: #ffffff;
|
||||
--c-band: #e9f0fb; /* solid tint behind the welcome hero */
|
||||
--c-border: #dbe3ee;
|
||||
--c-border-strong: #b3c0d1;
|
||||
--c-tile: #f3f7fb; /* soft card fill */
|
||||
--c-border: #e2e9f1;
|
||||
--c-border-strong: #c9d4e1;
|
||||
|
||||
/* Care tracks: one colour per thing the service does. The same colour marks the track everywhere
|
||||
(welcome preview, chat questions), so colour carries meaning, never decoration. */
|
||||
--c-meds: #0e8c7f;
|
||||
--c-meds-soft: #e2f4f1;
|
||||
--c-mood: #e79a0c;
|
||||
--c-mood-soft: #fdf1da;
|
||||
--c-mood-ink: #8a5800; /* text on mood-soft (AA) */
|
||||
--c-doctor: #2350c8;
|
||||
--c-doctor-soft: #e7eefc;
|
||||
/* Selection & positive: teal */
|
||||
--c-teal: #17a08f;
|
||||
--c-teal-strong: #0e7e71;
|
||||
--c-teal-soft: #e3f5f2;
|
||||
|
||||
/* Action colour = the doctor/ЕМИАС blue. */
|
||||
--c-primary: #2350c8;
|
||||
--c-primary-hover: #1c43ac;
|
||||
--c-primary-active: #163689;
|
||||
--c-primary-soft: #e7eefc;
|
||||
/* Info: blue */
|
||||
--c-blue: #2f7fdb;
|
||||
--c-blue-soft: #eaf3fd;
|
||||
|
||||
/* Mood scale 1 → 5 (heavy → good); used only for the scale. */
|
||||
--c-scale-1: #d9485f;
|
||||
--c-scale-2: #ef8a3c;
|
||||
--c-scale-3: #f2c230;
|
||||
--c-scale-4: #7cc35b;
|
||||
--c-scale-5: #2fa36b;
|
||||
/* Help & crisis: red */
|
||||
--c-red: #e2383b;
|
||||
--c-red-strong: #c62b2e;
|
||||
--c-red-soft: #fdeded;
|
||||
--c-red-line: #f7d3d3;
|
||||
|
||||
--c-danger: #c8283a;
|
||||
--c-danger-hover: #a91f2f;
|
||||
--c-danger-soft: #fdebed;
|
||||
--c-success: #1e8a55;
|
||||
--c-warning-soft: #fff4dc;
|
||||
--c-warning-border: #e7b448;
|
||||
--c-focus: #3b82f6;
|
||||
/* Warning */
|
||||
--c-amber: #e69a12;
|
||||
--c-amber-soft: #fff4dd;
|
||||
|
||||
/* Type: Golos Text — Russian open-source UI typeface (OFL), bundled locally so the app works offline. */
|
||||
--c-focus: #2f7fdb;
|
||||
|
||||
/* Type: Golos Text (OFL), bundled locally — works offline. */
|
||||
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--fs-12: 0.75rem;
|
||||
--fs-13: 0.8125rem;
|
||||
--fs-14: 0.875rem;
|
||||
--fs-16: 1rem;
|
||||
--fs-18: 1.125rem;
|
||||
--fs-22: 1.375rem;
|
||||
--fs-20: 1.25rem;
|
||||
--fs-24: 1.5rem;
|
||||
--fs-28: 1.75rem;
|
||||
--fs-40: 2.5rem;
|
||||
--lh-tight: 1.15;
|
||||
--lh-text: 1.5;
|
||||
--fs-34: 2.125rem;
|
||||
--fs-48: 3rem;
|
||||
|
||||
/* Spacing: 4 px grid. */
|
||||
/* Spacing: 4 px grid */
|
||||
--s-1: 4px;
|
||||
--s-2: 8px;
|
||||
--s-3: 12px;
|
||||
@@ -68,19 +61,17 @@
|
||||
--s-8: 32px;
|
||||
--s-10: 40px;
|
||||
--s-12: 48px;
|
||||
--s-16: 64px;
|
||||
|
||||
/* Radius hierarchy: containers > controls > fields > chips. */
|
||||
--r-card: 14px;
|
||||
--r-control: 10px;
|
||||
--r-field: 8px;
|
||||
--r-chip: 6px;
|
||||
/* Radii: tiles > buttons > chips */
|
||||
--r-tile: 16px;
|
||||
--r-btn: 12px;
|
||||
--r-chip: 999px;
|
||||
|
||||
--tap: 48px; /* minimum touch target */
|
||||
--header-h: 64px;
|
||||
--content-max: 1160px;
|
||||
--tap: 52px;
|
||||
--nav-h: 64px;
|
||||
--sidebar-w: 232px;
|
||||
--rail-w: 300px;
|
||||
|
||||
/* Safe areas (notch, home indicator) for the mobile shell. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
|
||||
Reference in New Issue
Block a user