feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile

- /flow renders the dialog engine's current step as a mock screen (consent,
  age, onboarding and scales with progress, mood 0–10, free text, dose
  reminder, crisis with hotlines and breathing, safety plan), so every
  engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
  emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
  clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
  prescription from the dashboard fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:11:45 +03:00
co-authored by Claude Opus 5.5
parent b8a93ce9d4
commit b4b1bc51c8
28 changed files with 3795 additions and 1770 deletions
+134 -1
View File
@@ -16,14 +16,19 @@ issued by the server instead of handing out the pseudonymous token itself.
import logging import logging
import secrets import secrets
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Literal from typing import Literal
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.api import fixtures from app.api import fixtures
from app.core import clock
from app.core.safety import CrisisResource, crisis_reply from app.core.safety import CrisisResource, crisis_reply
from app.db import repo from app.db import repo
from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN from app.db.identity import TOKEN_LEN
from app.db.session import get_clinical_session from app.db.session import get_clinical_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
@@ -56,6 +61,43 @@ async def _attach_doctor(token: str, doctor_id: str) -> None:
await repo.upsert_profile(s, token=token, doctor_id=doctor_id) await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
async def _seed_demo_persona(token: str) -> None:
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
doctor's card tell the same story.
"""
card = fixtures.card(token)
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
if card is None or item is None:
return
now = clock.now()
today = now.date()
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(
s,
token=token,
doctor_id=fixtures.DOCTOR_ID,
modules=list(item.modules),
therapy_start=today,
next_visit=today + timedelta(days=15),
)
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
return
for p in card.prescriptions:
rx = Prescription(token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=today)
s.add(rx)
await s.flush()
for t in p.schedule.get("times", []):
hh, mm = (int(x) for x in t.split(":"))
s.add(
Dose(
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
)
)
@router.post("/auth/emias", response_model=AuthOut) @router.post("/auth/emias", response_model=AuthOut)
async def auth_emias(body: EmiasCodeIn) -> AuthOut: async def auth_emias(body: EmiasCodeIn) -> AuthOut:
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor).""" """Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
@@ -74,7 +116,7 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None) persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None: if persona is None:
raise HTTPException(404, "persona not found") raise HTTPException(404, "persona not found")
await _attach_doctor(persona.token, fixtures.DOCTOR_ID) await _seed_demo_persona(persona.token)
log.info("web auth: emias demo tok=%s", persona.token[:6]) log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name) return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
@@ -87,6 +129,97 @@ async def auth_anonymous() -> AuthOut:
return AuthOut(token=token, mode="anonymous") return AuthOut(token=token, mode="anonymous")
class DoseItem(BaseModel):
id: int
drug: str
dose: str
time: str # HH:MM, patient's local time
status: Literal["taken", "missed", "skipped", "pending"]
class WeekDay(BaseModel):
day: date
status: Literal["taken", "missed", "pending", "none"]
class PrescriptionItem(BaseModel):
drug: str
dose: str
times: list[str]
week: list[WeekDay] # Mon..Sun of the current week
class TodayOut(BaseModel):
today: date
now: str # HH:MM on the (demo) clock — the client never uses its own clock
has_doctor: bool
checkin_done: bool
next_visit: date | None
doses: list[DoseItem]
prescriptions: list[PrescriptionItem]
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
if not statuses:
return "none"
if any(s in ("missed", "skipped") for s in statuses):
return "missed"
return "taken" if all(s == "taken" for s in statuses) else "pending"
@router.get("/me/{token}/today", response_model=TodayOut)
async def my_today(token: str) -> TodayOut:
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
"""
now = clock.now()
today = now.date()
week_start = today - timedelta(days=today.weekday())
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
doses = (
await s.scalars(
select(Dose)
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
.order_by(Dose.planned_at)
)
).all()
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
today_doses: list[DoseItem] = []
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
for d in doses:
local = d.planned_at.astimezone(now.tzinfo)
marks[(d.prescription_id, local.date())].append(d.status)
rx = by_rx.get(d.prescription_id)
if local.date() == today and rx is not None:
today_doses.append(
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
)
week = [week_start + timedelta(days=i) for i in range(7)]
return TodayOut(
today=today,
now=now.strftime("%H:%M"),
has_doctor=profile is not None,
checkin_done=checkin is not None and checkin.mood is not None,
next_visit=profile.next_visit if profile else None,
doses=today_doses,
prescriptions=[
PrescriptionItem(
drug=p.drug,
dose=p.dose,
times=list((p.schedule or {}).get("times", [])),
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
)
for p in prescriptions
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
],
)
@router.get("/crisis/resources", response_model=list[CrisisResource]) @router.get("/crisis/resources", response_model=list[CrisisResource])
async def crisis_resources() -> list[CrisisResource]: async def crisis_resources() -> list[CrisisResource]:
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script.""" """Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
+33
View File
@@ -1,6 +1,7 @@
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine.""" """Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, time, timedelta
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
@@ -9,6 +10,7 @@ from app.api import fixtures
from app.core import clock from app.core import clock
from app.db import identity_repo, repo, session from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.main import app from app.main import app
MEM = "sqlite+aiosqlite:///:memory:" MEM = "sqlite+aiosqlite:///:memory:"
@@ -81,6 +83,37 @@ async def test_emias_demo_persona(client: AsyncClient) -> None:
assert missing.status_code == 404 assert missing.status_code == 404
async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
now = clock.now()
today = now.date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5))
rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]})
s.add(rx)
await s.flush()
morning = datetime.combine(today, time(9, 0), now.tzinfo)
s.add_all(
[
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"),
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)),
]
)
await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6)
body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()
assert body["has_doctor"] and body["checkin_done"]
assert body["next_visit"] == (today + timedelta(days=5)).isoformat()
assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")]
week = body["prescriptions"][0]["week"]
assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending"
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()] phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
assert "112" in phones assert "112" in phones
+1
View File
@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo
.vite/
+12 -1
View File
@@ -30,9 +30,20 @@ src/theme/tokens.css цвета, шрифт, отступы — единств
src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок) src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
src/api/ типы-зеркала контрактов бэкенда и клиент src/api/ типы-зеркала контрактов бэкенда и клиент
src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain 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/` оборачивается Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API). Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
+2 -1
View File
@@ -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. // 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"; const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
@@ -35,6 +35,7 @@ export const api = {
signInAnonymous: () => post<AuthResult>("/auth/anonymous"), signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
demoPersonas: () => request<DemoPersona[]>("/demo/personas"), demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"), crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
sendEvent: async (token: string, event: DialogEvent) => sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) => outbox: async (token: string) =>
+27
View File
@@ -39,3 +39,30 @@ export interface DemoPersona {
therapy_day: number; therapy_day: number;
scenario: string | null; 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[];
}
+156 -44
View File
@@ -1,64 +1,176 @@
import { useState, type ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link, NavLink } from "react-router-dom";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { HelpSheet } from "./HelpSheet"; import { useDialog } from "../dialog/DialogContext";
import { Icon, LogoMark } from "./Icon"; 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 ( return (
<div className="shell"> <section className="sosblock" aria-label={ru.help.block}>
<header className="topbar"> <button type="button" className="sosblock__head" onClick={open}>
<div className="topbar__inner"> <span className="sosblock__icon">
<Link to="/" className="brand" aria-label={ru.product.name}> <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>
);
}
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 /> <LogoMark />
<span className="brand__text"> <span>
<span className="brand__name">{ru.product.name}</span> <span className="sidebar__name">{ru.product.name}</span>
<span className="brand__tagline">{ru.product.tagline}</span> <span className="sidebar__tag">{ru.product.tagline}</span>
</span> </span>
</Link> </Link>
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}> {signedIn ? (
<Icon name="phone" size={18} /> <ul className="sidebar__list">
<span>{ru.help.button}</span> {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> </button>
</div> </li>
</header> </ul>
{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>
)} )}
</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> </div>
); );
} }
export function BackLink({ to, children }: { to: string; children: ReactNode }) { export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
return ( const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
<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 }) {
return ( return (
<div className={`notice notice--${tone}`} role="note"> <div className={`notice notice--${tone}`} role="note">
<Icon name={tone === "warning" ? "alert" : "info"} size={20} /> <Icon name={icon} />
<p>{children}</p> <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> </div>
); );
} }
+72 -26
View File
@@ -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 { api } from "../api/client";
import type { CrisisResource } from "../api/types"; import type { CrisisResource } from "../api/types";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
// 112 is shown even when the API is unreachable: the help sheet must never be empty. // 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: ru.help.fallbackNote }]; 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 }) { interface HelpApi {
const ref = useRef<HTMLDialogElement>(null); 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 [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
const [isOpen, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
api api
@@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
.catch(() => undefined); .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(() => { useEffect(() => {
const dialog = ref.current; const dialog = ref.current;
if (!dialog) return; if (!dialog) return;
@@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
onClick={(e) => e.target === ref.current && onClose()} onClick={(e) => e.target === ref.current && onClose()}
> >
<div className="sheet__body"> <div className="sheet__body">
<div className="sheet__head"> <div className="sheet__bar">
<h2 id="help-title" className="sheet__title">
{ru.help.title}
</h2>
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}> <button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
<Icon name="close" /> <Icon name="close" />
</button> </button>
<h2 id="help-title" className="sheet__title">
{ru.help.title}
</h2>
</div> </div>
<p className="sheet__lead">{ru.help.lead}</p> <div className="sos">
<ul className="hotlines"> <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) => ( {resources.map((r) => (
<li key={r.phone}> <HotlineCard key={r.phone} r={r} />
<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>
))} ))}
</ul> </div>
<p className="notice notice--info">
<Icon name="info" />
<span>{ru.help.alwaysNote}</span>
</p>
</div> </div>
</dialog> </dialog>
); );
+104 -26
View File
@@ -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 = { const paths = {
check: <path d="M5 12.5l4.5 4.5L19 7.5" />, 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" />,
minus: <path d="M6 12h12" />, pill: (
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" /> <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" />, arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />, close: <path d="M6 6l12 12M18 6L6 18" />,
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />, check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
x: <path d="M7 7l10 10M17 7L7 17" />,
info: ( info: (
<> <>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -21,33 +44,88 @@ const paths = {
<path d="M12 10v4.5m0 2.5v.5" /> <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: ( 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" /> <path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
</> </>
), ),
pill: ( 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" />,
<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" />,
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" />, 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; } as const;
export type IconName = keyof typeof paths; 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 ( return (
<svg <svg
width={size} width={size}
@@ -55,7 +133,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth={1.75} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
role={label ? "img" : undefined} 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 }) { export function LogoMark({ size = 32 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false"> <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" /> <path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
</svg> </svg>
); );
+128 -63
View File
@@ -1,100 +1,165 @@
/* /*
* All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/ * All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales,
* for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here. * reminders — comes from the engine (config/texts/) and is NOT duplicated here.
*/ */
export const ru = { export const ru = {
product: { product: {
name: "Сопровождение пациента", name: "Сопровождение пациента",
tagline: "между визитами к врачу", tagline: "между визитами к врачу",
prototype: "Прототип · синтетические данные", },
nav: {
today: "Сегодня",
meds: "Лекарства",
doctor: "Врач",
profile: "Профиль",
messages: "Сообщения",
signIn: "Вход",
help: "Помощь",
}, },
help: { help: {
button: "Нужна помощь", block: "Экстренная помощь",
title: "Если вам сейчас очень тяжело", title: "Экстренная помощь",
lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:", lead: "Если вам нужна срочная помощь, позвоните сразу.",
call: "Позвонить", call: "Позвонить",
close: "Закрыть", close: "Закрыть",
fallbackName: "Экстренные службы", alwaysNote: "Вы не одни. Помощь доступна круглосуточно.",
fallbackNote: "если есть угроза жизни прямо сейчас", fallbackName: "Единый номер экстренных служб",
fallbackNote: "",
},
common: {
back: "Назад",
next: "Далее",
skip: "Пропустить",
send: "Отправить",
retry: "Повторить",
toToday: "Вернуться на сегодня",
recommended: "Рекомендуем",
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
}, },
welcome: { welcome: {
title: "Лечение продолжается и между приёмами", title: "Как вы хотите войти в приложение?",
lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.", 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: "Как вы хотите войти?",
emias: { emias: {
title: "Через ЕМИАС", title: "Через ЕМИАС",
badge: "Рекомендуем", text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
facts: [
{ ok: true, text: "Назначения врача подгружаются автоматически" },
{ ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" },
{ ok: true, text: "История доступна с любого устройства" },
],
action: "Войти через ЕМИАС",
}, },
anonymous: { anonymous: {
title: "Анонимно", title: "Анонимно",
facts: [ text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
{ ok: true, text: "Не нужны имя, полис и телефон" },
{ ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" },
{ ok: false, text: "Врач не видит ваши ответы и не получает сигналов" },
{ ok: false, text: "История доступна только на этом устройстве" },
],
action: "Продолжить анонимно",
}, },
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.", disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
}, },
emias: { emias: {
back: "Способы входа", title: "Введите код от врача",
title: "Вход через ЕМИАС", lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.",
demoNotice: demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.",
"Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.", codeLabel: "Код подключения",
codeLabel: "Код подключения от врача",
codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.",
codePlaceholder: "Код из памятки", codePlaceholder: "Код из памятки",
submit: "Войти", submit: "Продолжить",
submitting: "Проверяем код…", submitting: "Проверяем код…",
codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.", codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.",
networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.",
or: "или", or: "или",
demoTitle: "Демо-пациенты", demoTitle: "Демо-пациенты",
demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.", demoLead: "Нет кода? Войдите под синтетической учётной записью.",
demoDay: (day: number) => `${day}-й день терапии`, demoDay: (day: number) => `${day}-й день терапии`,
demoAction: "Войти",
demoEmpty: "Демо-пациенты недоступны.",
}, },
anonymous: { anonymous: {
back: "Способы входа",
title: "Анонимный вход", title: "Анонимный вход",
lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.", lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
points: [ yesTitle: "Что будет доступно:",
"Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.", yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
"Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.", noTitle: "Чего не будет:",
"Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.", no: ["Врач не увидит ответы", "История только на этом устройстве"],
], warning: "После выхода историю восстановить нельзя.",
warning: submit: "Продолжить анонимно",
"История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.", submitting: "Создаём профиль…",
submit: "Начать анонимно",
submitting: "Создаём анонимный профиль…",
}, },
chat: { today: {
modeEmias: "ЕМИАС", title: "Сегодня",
modeAnonymous: "Анонимно", 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: "Выйти", 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: "Сообщение", inputLabel: "Сообщение",
inputPlaceholder: "Напишите сообщение", inputPlaceholder: "Напишите сообщение",
send: "Отправить",
loading: "Подключаемся…",
sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.",
you: "Вы", you: "Вы",
assistant: "Помощник", empty: "Здесь будут сообщения помощника.",
crisisLabel: "Важно",
}, },
footer: { footer: {
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).", law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
+115
View File
@@ -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;
}
+108
View File
@@ -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;
}
+27 -4
View File
@@ -1,28 +1,47 @@
import "@fontsource/golos-text/400.css"; import "@fontsource/golos-text/400.css";
import "@fontsource/golos-text/500.css"; import "@fontsource/golos-text/500.css";
import "@fontsource/golos-text/600.css"; import "@fontsource/golos-text/600.css";
import "@fontsource/golos-text/700.css";
import "./theme/tokens.css"; import "./theme/tokens.css";
import "./theme/global.css"; import "./theme/global.css";
import { StrictMode } from "react"; import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; 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 { AnonymousSignIn } from "./screens/AnonymousSignIn";
import { Chat } from "./screens/Chat"; import { Doctor } from "./screens/Doctor";
import { EmiasSignIn } from "./screens/EmiasSignIn"; 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 { Welcome } from "./screens/Welcome";
import { SessionProvider, useSession } from "./session/SessionContext"; 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. // Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
function AppRoutes() { function AppRoutes() {
const { session } = useSession(); const { session } = useSession();
return ( return (
<Routes> <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="/emias" element={<EmiasSignIn />} />
<Route path="/anonymous" element={<AnonymousSignIn />} /> <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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
@@ -31,9 +50,13 @@ function AppRoutes() {
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<SessionProvider> <SessionProvider>
<DialogProvider>
<HelpProvider>
<HashRouter> <HashRouter>
<AppRoutes /> <AppRoutes />
</HashRouter> </HashRouter>
</HelpProvider>
</DialogProvider>
</SessionProvider> </SessionProvider>
</StrictMode>, </StrictMode>,
); );
+49 -25
View File
@@ -2,11 +2,15 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import { AppShell, BackLink, Notice } from "../components/AppShell"; import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon"; import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext"; import { useSession } from "../session/SessionContext";
const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
const NO_ICONS: IconName[] = ["user", "doc"];
// A3 · Анонимный вход
export function AnonymousSignIn() { export function AnonymousSignIn() {
const t = ru.anonymous; const t = ru.anonymous;
const navigate = useNavigate(); const navigate = useNavigate();
@@ -19,7 +23,7 @@ export function AnonymousSignIn() {
setError(false); setError(false);
try { try {
signIn(await api.signInAnonymous()); signIn(await api.signInAnonymous());
navigate("/chat", { replace: true }); navigate("/flow", { replace: true });
} catch { } catch {
setError(true); setError(true);
setBusy(false); setBusy(false);
@@ -27,35 +31,55 @@ export function AnonymousSignIn() {
}; };
return ( return (
<AppShell> <AppShell signedIn={false} focus>
<main className="page" id="main"> <div className="stack narrow">
<div className="narrow"> <TopBar onBack={() => navigate("/")} />
<BackLink to="/">{t.back}</BackLink> <header className="head">
<section className="panel" aria-labelledby="anon-title"> <h1 className="h1">{t.title}</h1>
<h1 id="anon-title" className="h2"> <p className="lead">{t.lead}</p>
{t.title} </header>
</h1>
<p className="lead lead--sm">{t.lead}</p> <section className="panel panel--teal">
<ul className="facts facts--spaced"> <p className="panel__title">
{t.points.map((p) => ( <span className="badge-icon badge-icon--teal">
<li key={p} className="fact"> <Icon name="check" size={16} />
<Icon name="check" size={18} /> </span>
<span>{p}</span> {t.yesTitle}
</p>
<ul className="iconlist">
{t.yes.map((item, i) => (
<li key={item}>
<Icon name={YES_ICONS[i]} size={20} />
{item}
</li> </li>
))} ))}
</ul> </ul>
<Notice tone="warning">{t.warning}</Notice> </section>
{error && (
<p className="field__error" role="alert"> <section className="panel panel--red">
{ru.emias.networkError} <p className="panel__title">
<span className="badge-icon badge-icon--red">
<Icon name="x" size={16} />
</span>
{t.noTitle}
</p> </p>
)} <ul className="iconlist">
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}> {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} {busy ? t.submitting : t.submit}
</button> </button>
</section>
</div> </div>
</main>
</AppShell> </AppShell>
); );
} }
-267
View File
@@ -1,267 +0,0 @@
import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { DialogButton, DialogEvent, Reply } from "../api/types";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { sessionStore, type ChatEntry } from "../session/store";
// The engine pushes reminders and check-ins to the outbox; the web channel polls it (Telegram gets them pushed).
const OUTBOX_POLL_MS = 6000;
let seq = 0;
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
const assistantEntries = (replies: Reply[]): ChatEntry[] =>
replies.map((reply) => ({ id: newId(), from: "assistant", reply, answered: false }));
// Phone numbers in engine texts (crisis script, help lines) become tap-to-call links.
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|8 \(\d{3}\) \d{3}|\b11[2]\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
return (
<>
{text.split("\n").map((line, i) => (
<Fragment key={i}>
{i > 0 && <br />}
{line.split(PHONE_RE).map((part, j) =>
j % 2 === 1 ? (
<a key={j} href={`tel:${part.replace(/[^\d+]/g, "")}`} className="tel">
{part}
</a>
) : (
<Fragment key={j}>{part}</Fragment>
),
)}
</Fragment>
))}
</>
);
}
function AssistantMessage({
entry,
disabled,
onPress,
}: {
entry: Extract<ChatEntry, { from: "assistant" }>;
disabled: boolean;
onPress: (button: DialogButton) => void;
}) {
const { reply } = entry;
const special = reply.kind === "crisis" || reply.kind === "safety_plan";
return (
<li className={`msg msg--assistant msg--${reply.kind}`}>
<div className="msg__bubble">
{special && (
<p className="msg__label">
<Icon name="alert" size={16} />
{ru.chat.crisisLabel}
</p>
)}
<p className="msg__text">
<RichText text={reply.text} />
</p>
</div>
{reply.buttons.length > 0 && !entry.answered && (
<div className="choices">
{reply.buttons.map((row, r) => (
<div key={r} className="choices__row">
{row.map((b) => (
<button
key={b.data}
type="button"
className="choice"
disabled={disabled}
onClick={() => onPress(b)}
>
{b.label}
</button>
))}
</div>
))}
</div>
)}
</li>
);
}
export function Chat() {
const t = ru.chat;
const navigate = useNavigate();
const { session, signOut } = useSession();
const token = session?.token ?? "";
const [entries, setEntries] = useState<ChatEntry[]>(() => (token ? sessionStore.loadChat(token) : []));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
const started = useRef(false);
useEffect(() => {
if (token) sessionStore.saveChat(token, entries);
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, [entries, token]);
// Any patient action closes the open buttons: the engine has moved on, old choices are stale.
const dispatch = useCallback(
async (event: DialogEvent, optimistic: ChatEntry[]) => {
setBusy(true);
setError(false);
let reopened = new Set<string>();
setEntries((prev) => {
reopened = new Set(prev.filter((e) => e.from === "assistant" && !e.answered).map((e) => e.id));
return [...prev.map((e) => (reopened.has(e.id) ? { ...e, answered: true } : e)), ...optimistic];
});
try {
const replies = await api.sendEvent(token, event);
setEntries((prev) => [...prev, ...assistantEntries(replies)]);
return true;
} catch {
// Roll back the optimistic patient message and re-open the buttons.
const ids = new Set(optimistic.map((o) => o.id));
setEntries((prev) =>
prev.filter((e) => !ids.has(e.id)).map((e) => (reopened.has(e.id) ? { ...e, answered: false } : e)),
);
setError(true);
return false;
} finally {
setBusy(false);
}
},
[token],
);
// First visit: the engine opens the dialog (consent → onboarding) exactly as for /start in Telegram.
useEffect(() => {
if (!token || started.current) return;
started.current = true;
if (entries.length === 0) void dispatch({ type: "start" }, []);
}, [token, entries.length, dispatch]);
useEffect(() => {
if (!token) return;
const timer = window.setInterval(() => {
api
.outbox(token)
.then((replies) => replies.length && setEntries((prev) => [...prev, ...assistantEntries(replies)]))
.catch(() => undefined);
}, OUTBOX_POLL_MS);
return () => window.clearInterval(timer);
}, [token]);
useEffect(() => {
if (!session) navigate("/", { replace: true });
}, [session, navigate]);
if (!session) return null;
const sendText = async (e?: FormEvent) => {
e?.preventDefault();
const text = draft.trim();
if (!text || busy) return;
setDraft("");
const ok = await dispatch({ type: "text", text }, [{ id: newId(), from: "patient", text }]);
if (!ok) setDraft(text);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) void sendText();
};
const press = (b: DialogButton) =>
void dispatch({ type: "button", button: b.data }, [{ id: newId(), from: "patient", text: b.label }]);
return (
<AppShell footer={false}>
<div className="chat-bar">
<div className="chat-bar__inner">
<span className={`mode mode--${session.mode}`}>
<Icon name={session.mode === "emias" ? "doctor" : "lock"} size={16} />
{session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
{session.displayName && <span className="mode__name">· {session.displayName}</span>}
</span>
{confirmLogout ? (
<span className="logout-confirm" role="group" aria-label={t.logout}>
<span className="logout-confirm__text">{t.logoutConfirm}</span>
<button type="button" className="btn btn--danger btn--sm" onClick={signOut}>
{t.logout}
</button>
<button type="button" className="icon-btn" aria-label={ru.help.close} onClick={() => setConfirmLogout(false)}>
<Icon name="close" size={18} />
</button>
</span>
) : (
<button
type="button"
className="link-btn"
onClick={() => (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
>
<Icon name="logout" size={18} />
{t.logout}
</button>
)}
</div>
</div>
<main className="chat" id="main">
<ol className="messages" aria-live="polite" aria-label={t.assistant}>
{entries.map((entry) =>
entry.from === "assistant" ? (
<AssistantMessage key={entry.id} entry={entry} disabled={busy} onPress={press} />
) : (
<li key={entry.id} className="msg msg--patient">
<span className="visually-hidden">{t.you}: </span>
<div className="msg__bubble">
<p className="msg__text">{entry.text}</p>
</div>
</li>
),
)}
{busy && (
<li className="msg msg--assistant msg--typing" aria-label={t.loading}>
<div className="msg__bubble">
<span className="dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</div>
</li>
)}
</ol>
<div ref={endRef} />
</main>
<form className="composer" onSubmit={sendText}>
{error && (
<p className="composer__error" role="alert">
{t.sendError}
</p>
)}
<div className="composer__row">
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
className="composer__input"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<button type="submit" className="send-btn" disabled={busy || !draft.trim()} aria-label={t.send}>
<Icon name="send" size={22} />
</button>
</div>
</form>
</AppShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useNavigate } from "react-router-dom";
import { AppShell, Notice } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { humanDate, useToday } from "./useToday";
// F1 / F2 · Врач: следующий визит и связь с врачом (действия выполняет движок диалога)
export function Doctor() {
const t = ru.doctor;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
const actions: { button: string; label: string; icon: IconName }[] = [
{ button: "menu:contact_doctor", label: t.write, icon: "message" },
{ button: "menu:visit", label: t.earlier, icon: "calendar" },
{ button: "safety_plan", label: t.plan, icon: "shield" },
];
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
</header>
{plan && !plan.has_doctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<>
<section className="card-list" aria-label={t.visit}>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span>
<span className="row-card__sub">{t.visit}</span>
<span className="row-card__title">{plan?.next_visit ? humanDate(plan.next_visit) : t.visitNone}</span>
</span>
</div>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
<Icon name="doctor" />
</span>
<span className="row-card__title">{t.attending}</span>
</div>
</section>
<div className="stack stack--sm">
{actions.map((a) => (
<button key={a.button} type="button" className="row-card" onClick={() => go(a.button, a.label)} disabled={dialog.busy}>
<span className="tile-icon">
<Icon name={a.icon} />
</span>
<span className="row-card__text">
<span className="row-card__title">{a.label}</span>
</span>
<Icon name="chevronRight" />
</button>
))}
</div>
<Notice>{t.writeNote}</Notice>
</>
)}
</div>
</AppShell>
);
}
+33 -49
View File
@@ -3,11 +3,12 @@ import { useNavigate } from "react-router-dom";
import { ApiError, api } from "../api/client"; import { ApiError, api } from "../api/client";
import type { AuthResult, DemoPersona } from "../api/types"; 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 { Icon } from "../components/Icon";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext"; import { useSession } from "../session/SessionContext";
// A2 · Вход через ЕМИАС (демо: одноразовый код от врача или синтетический пациент)
export function EmiasSignIn() { export function EmiasSignIn() {
const t = ru.emias; const t = ru.emias;
const navigate = useNavigate(); const navigate = useNavigate();
@@ -26,11 +27,11 @@ export function EmiasSignIn() {
const finish = (auth: AuthResult) => { const finish = (auth: AuthResult) => {
signIn(auth); signIn(auth);
navigate("/chat", { replace: true }); navigate("/flow", { replace: true });
}; };
const fail = (e: unknown) => { 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); setBusy(false);
}; };
@@ -57,24 +58,21 @@ export function EmiasSignIn() {
}; };
return ( return (
<AppShell> <AppShell signedIn={false} focus>
<main className="page" id="main"> <div className="stack narrow">
<div className="narrow"> <TopBar onBack={() => navigate("/")} />
<BackLink to="/">{t.back}</BackLink> <header className="head">
<section className="panel" aria-labelledby="emias-title"> <h1 className="h1">{t.title}</h1>
<h1 id="emias-title" className="h2"> <p className="lead">{t.lead}</p>
{t.title} </header>
</h1>
<Notice>{t.demoNotice}</Notice>
<form className="form" onSubmit={submit} noValidate> <form className="stack stack--sm" onSubmit={submit} noValidate>
<div className={error ? "field field--error" : "field"}> <label htmlFor="code" className="visually-hidden">
<label htmlFor="code" className="field__label">
{t.codeLabel} {t.codeLabel}
</label> </label>
<input <input
id="code" id="code"
className="input" className={error ? "code-input is-error" : "code-input"}
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
placeholder={t.codePlaceholder} placeholder={t.codePlaceholder}
@@ -83,55 +81,41 @@ export function EmiasSignIn() {
spellCheck={false} spellCheck={false}
maxLength={32} maxLength={32}
aria-invalid={error ? true : undefined} aria-invalid={error ? true : undefined}
aria-describedby={error ? "code-error" : "code-hint"} aria-describedby={error ? "code-error" : undefined}
/> />
{error ? ( {error && (
<p id="code-error" className="field__error" role="alert"> <div id="code-error" role="alert">
{error} <Notice tone="danger">{error}</Notice>
</p>
) : (
<p id="code-hint" className="field__hint">
{t.codeHint}
</p>
)}
</div> </div>
<button type="submit" className="btn btn--primary btn--block" disabled={busy || !code.trim()}> )}
<button type="submit" className="btn btn--primary" disabled={busy || !code.trim()}>
{busy ? t.submitting : t.submit} {busy ? t.submitting : t.submit}
</button> </button>
</form> </form>
<Notice>{t.demoNotice}</Notice>
<div className="divider" role="separator"> <div className="divider" role="separator">
<span>{t.or}</span> <span>{t.or}</span>
</div> </div>
<h2 className="h3">{t.demoTitle}</h2> <section className="stack stack--sm" aria-labelledby="demo-title">
<h2 id="demo-title" className="h3">
{t.demoTitle}
</h2>
<p className="muted">{t.demoLead}</p> <p className="muted">{t.demoLead}</p>
{personas === null ? null : personas.length === 0 ? ( {personas?.map((p) => (
<p className="muted">{t.demoEmpty}</p> <button key={p.token} type="button" className="row-card" onClick={() => demo(p)} disabled={busy}>
) : ( <span className="avatar">{p.display_name.charAt(0)}</span>
<ul className="list"> <span className="row-card__text">
{personas.map((p) => ( <span className="row-card__title">{p.display_name}</span>
<li key={p.token}> <span className="row-card__sub">{t.demoDay(p.therapy_day)}</span>
<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> </span>
<Icon name="chevronRight" />
</button> </button>
</li>
))} ))}
</ul>
)}
</section> </section>
</div> </div>
</main>
</AppShell> </AppShell>
); );
} }
+466
View File
@@ -0,0 +1,466 @@
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step } from "../dialog/step";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3).
*/
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
return (
<>
{text.split(PHONE_RE).map((part, i) =>
i % 2 === 1 ? (
<a key={i} href={telHref(part)} className="tel">
{part}
</a>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
function Body({ texts }: { texts: string[] }) {
if (!texts.length) return null;
return (
<div className="body-text">
{texts.map((t, i) => {
const ls = lines(t);
return (
<div key={i} className="body-text__block">
{ls.map((l, j) =>
l.list ? (
<ul key={j} className="bullets">
<li>
<RichText text={l.text} />
</li>
</ul>
) : (
<p key={j}>
<RichText text={l.text} />
</p>
),
)}
</div>
);
})}
</div>
);
}
function Progress({ n, total }: { n: number; total: number }) {
return (
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
<span className="progress__bar">
<span style={{ width: `${(n / total) * 100}%` }} />
</span>
<span className="progress__label">{ru.flow.question(n, total)}</span>
</div>
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
<header className="head">
{icon && (
<span className="tile-icon tile-icon--blue">
<Icon name={icon} size={26} />
</span>
)}
{title && <h1 className="h1">{title}</h1>}
<Body texts={lead} />
</header>
);
}
function Choice({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="options" role="radiogroup">
{options.map((b) => {
const on = picked?.data === b.data;
return (
<button
key={b.data}
type="button"
role="radio"
aria-checked={on}
className={on ? "option is-on" : "option"}
onClick={() => setPicked(b)}
>
<span className="option__mark">{on && <Icon name="check" size={16} />}</span>
<span className="option__label">{b.label}</span>
</button>
);
})}
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
function NumberScale({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="numscale">
<span className="numscale__value" aria-live="polite">
{picked ? picked.label : "–"}
</span>
<div className="numscale__row" role="radiogroup">
{options.map((b) => (
<button
key={b.data}
type="button"
role="radio"
aria-checked={picked?.data === b.data}
className={picked?.data === b.data ? "num is-on" : "num"}
onClick={() => setPicked(b)}
>
{b.label}
</button>
))}
</div>
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
function FreeText({
skip,
busy,
onSend,
onSkip,
onExit,
}: {
skip?: DialogButton;
busy: boolean;
onSend: (text: string) => void;
onSkip: (b: DialogButton) => void;
onExit: () => void;
}) {
const [text, setText] = useState("");
return (
<>
<div className="textarea">
<label htmlFor="free" className="visually-hidden">
{ru.messages.inputLabel}
</label>
<textarea
id="free"
rows={5}
maxLength={500}
value={text}
placeholder={ru.flow.textPlaceholder}
onChange={(e) => setText(e.target.value)}
/>
<span className="textarea__count">{text.length}/500</span>
</div>
<div className="btn-row">
{skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
{skip.label}
</button>
) : (
<button type="button" className="btn btn--ghost" onClick={onExit}>
{ru.flow.exit}
</button>
)}
<button
type="button"
className="btn btn--primary"
disabled={!text.trim() || busy}
onClick={() => onSend(text.trim())}
>
{ru.common.next}
</button>
</div>
</>
);
}
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
const style = (b: DialogButton) =>
b.data.startsWith("med:taken")
? { cls: "act act--teal", icon: "check" as IconName }
: b.data.startsWith("med:side_effects")
? { cls: "act act--red", icon: "alert" as IconName }
: { cls: "act", icon: "clock" as IconName };
return (
<div className="acts">
{options.map((b) => {
const s = style(b);
return (
<button key={b.data} type="button" className={s.cls} disabled={busy} onClick={() => onPress(b)}>
<span className="act__icon">
<Icon name={s.icon} size={20} />
</span>
{b.label}
</button>
);
})}
</div>
);
}
function Breathing() {
return (
<section className="breath" aria-label={ru.flow.breathe}>
<Icon name="lungs" size={28} />
<div className="breath__text">
<p className="breath__title">{ru.flow.breathe}</p>
<div className="breath__viz">
<span>{ru.flow.breatheIn}</span>
<span className="breath__ring" aria-hidden="true" />
<span>{ru.flow.breatheOut}</span>
</div>
</div>
</section>
);
}
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
<div className="panel">
<Body texts={notes} />
</div>
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
</div>
);
}
export function Flow() {
const navigate = useNavigate();
const dialog = useDialog();
const { resources } = useHelp();
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
const home = () => navigate("/today");
// First visit after sign-in: the engine opens the dialog (consent → age → onboarding), as /start in Telegram.
useEffect(() => {
if (!dialog.started && !dialog.busy) void dialog.start();
}, []);
useEffect(() => {
dialog.markRead();
}, [dialog.pending]);
useEffect(() => {
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
}, [step, navigate]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
if (!step) {
content = dialog.error ? (
<div className="stack narrow">
<Notice tone="danger">{ru.common.networkError}</Notice>
<button type="button" className="btn btn--primary" onClick={() => void dialog.start()}>
{ru.common.retry}
</button>
</div>
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
} else {
const top = (
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
);
let body: ReactNode;
switch (step.kind) {
case "consent":
body = (
<>
<Head title={ru.flow.consentTitle} lead={[]} icon="shield" />
<div className="panel panel--scroll">
<Body texts={[...step.lead, step.title].filter(Boolean)} />
</div>
<div className="btn-row">
{step.buttons.map((b, i) => (
<button
key={b.data}
type="button"
className={i === 0 ? "btn btn--primary" : "btn btn--outline"}
disabled={dialog.busy}
onClick={() => press(b)}
>
{b.label}
</button>
))}
</div>
</>
);
break;
case "number":
body = (
<>
<Head title={step.title} lead={step.lead} />
<NumberScale key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
</>
);
break;
case "actions":
body = (
<>
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{step.title || ru.meds.reminderTitle}</h1>
<Body texts={step.lead} />
</div>
<MedActions options={step.options} busy={dialog.busy} onPress={press} />
</>
);
break;
case "text":
body = (
<>
<Head title={step.title} lead={step.lead} />
<FreeText
key={stepKey}
skip={step.skip}
busy={dialog.busy}
onSend={say}
onSkip={press}
onExit={home}
/>
</>
);
break;
case "crisis":
body = (
<>
<header className="head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="heart" size={32} />
</span>
<h1 className="h1">{ru.flow.supportTitle}</h1>
</header>
<div className="hotlines">
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} />
))}
</div>
<Breathing />
<div className="panel">
<Body texts={[step.text]} />
</div>
{step.buttons.map((b) => (
<button key={b.data} type="button" className="btn btn--outline" disabled={dialog.busy} onClick={() => press(b)}>
{b.label}
</button>
))}
<button
type="button"
className="row-card"
disabled={dialog.busy}
onClick={() => press({ label: ru.flow.openPlan, data: "safety_plan" })}
>
<Icon name="shield" />
<span className="row-card__text">
<span className="row-card__title">{ru.flow.openPlan}</span>
</span>
<Icon name="chevronRight" />
</button>
</>
);
break;
case "plan":
body = (
<>
<Head title={step.title || ru.flow.planTitle} lead={step.lead} />
<ul className="plan">
{step.lines.map((l) => (
<li key={l} className="plan__item">
<Icon name="shield" size={20} />
<span>{l}</span>
</li>
))}
</ul>
<button type="button" className="btn btn--primary" onClick={home}>
{ru.common.toToday}
</button>
</>
);
break;
case "choice":
body = (
<>
<Head title={step.title} lead={step.lead} />
<Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
</>
);
break;
}
content = (
<div className="stack narrow" key={stepKey}>
{top}
{body}
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
);
}
return (
<AppShell focus sos={step?.kind !== "crisis"}>
{content}
</AppShell>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useNavigate, useParams } from "react-router-dom";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useToday } from "./useToday";
const DOT: Record<string, { cls: string; icon?: IconName }> = {
taken: { cls: "wk wk--taken", icon: "check" },
missed: { cls: "wk wk--missed", icon: "x" },
pending: { cls: "wk wk--pending" },
none: { cls: "wk" },
};
// E4 · Моя схема приёма
export function Meds() {
const t = ru.meds;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const ask = () =>
void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow"));
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
{plan && plan.prescriptions.length === 0 && <Notice>{t.empty}</Notice>}
{plan?.prescriptions.map((p) => (
<article key={p.drug} className="rx">
<div className="rx__head">
<span className="tile-icon tile-icon--teal">
<Icon name="pill" />
</span>
<span>
<span className="rx__drug">{p.drug.charAt(0).toUpperCase() + p.drug.slice(1)}</span>
<span className="rx__by">
{p.dose} · {t.byDoctor}
</span>
</span>
</div>
<ul className="rx__times">
{p.times.map((time) => (
<li key={time}>
<Icon name="clock" size={18} />
<strong>{time}</strong>
<span>{t.tablet}</span>
</li>
))}
</ul>
<ol className="week" aria-label="Отметки за неделю">
{p.week.map((w, i) => (
<li key={w.day} className="week__day">
<span className="week__label">{t.week[i]}</span>
<span className={DOT[w.status].cls} aria-label={w.status}>
{DOT[w.status].icon && <Icon name={DOT[w.status].icon!} size={14} />}
</span>
</li>
))}
</ol>
</article>
))}
{plan && plan.prescriptions.length > 0 && <Notice>{t.onlyDoctor}</Notice>}
{plan?.has_doctor && (
<button type="button" className="btn btn--outline" onClick={ask} disabled={dialog.busy}>
<Icon name="question" />
{t.question}
</button>
)}
</div>
</AppShell>
);
}
// E1 · Напоминание о приёме (opened from «Сегодня»); answers go to the engine like the Telegram reminder buttons.
export function DoseReminder() {
const t = ru.meds;
const { id } = useParams();
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const dose = plan?.doses.find((d) => String(d.id) === id);
const answer = (data: string, label: string) =>
void dialog.send({ type: "button", button: `${data}:${id}` }, label).then((ok) => ok && navigate("/flow"));
return (
<AppShell focus>
<div className="stack narrow">
<TopBar onBack={() => navigate("/today")} />
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{t.reminderTitle}</h1>
<p className="muted center">{t.reminderLead}</p>
{dose && (
<div className="reminder__time">
<Icon name="clock" />
<span>{dose.time}</span>
</div>
)}
{dose && (
<p className="reminder__drug">
{dose.drug.charAt(0).toUpperCase() + dose.drug.slice(1)}, {dose.dose}
</p>
)}
</div>
<div className="acts">
<button type="button" className="act act--teal" disabled={dialog.busy} onClick={() => answer("med:taken", t.taken)}>
<span className="act__icon">
<Icon name="check" size={20} />
</span>
{t.taken}
</button>
<button type="button" className="act" disabled={dialog.busy} onClick={() => answer("med:skip", t.skip)}>
<span className="act__icon">
<Icon name="clock" size={20} />
</span>
{t.skip}
</button>
<button
type="button"
className="act act--red"
disabled={dialog.busy}
onClick={() => answer("med:side_effects", t.sideEffects)}
>
<span className="act__icon">
<Icon name="alert" size={20} />
</span>
{t.sideEffects}
</button>
</div>
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</AppShell>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
// I1 · Сообщения: вся переписка с помощником; быстрые ответы — кнопки последнего сообщения движка.
export function Messages() {
const t = ru.messages;
const navigate = useNavigate();
const dialog = useDialog();
const [draft, setDraft] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const quick = dialog.pending.flatMap((r) => r.buttons.flat());
useEffect(() => {
dialog.markRead();
endRef.current?.scrollIntoView({ block: "end" });
}, [dialog.entries.length]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
const text = draft.trim();
if (!text || dialog.busy) return;
setDraft("");
if (!(await dialog.send({ type: "text", text }, text))) setDraft(text);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send();
}
};
return (
<AppShell>
<div className="stack chat">
<TopBar onBack={() => navigate("/today")} title={t.title} />
{dialog.entries.length === 0 && <p className="muted">{t.empty}</p>}
<ol className="bubbles" aria-live="polite">
{dialog.entries.map((e) =>
e.from === "assistant" ? (
<li key={e.id} className={`bubble bubble--bot bubble--${e.reply.kind}`}>
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<p>{e.reply.text}</p>
</li>
) : (
<li key={e.id} className="bubble bubble--me">
<span className="visually-hidden">{t.you}: </span>
<p>{e.text}</p>
</li>
),
)}
</ol>
{quick.length > 0 && (
<div className="quickreplies">
{quick.map((b) => (
<button
key={b.data}
type="button"
className="qr"
disabled={dialog.busy}
onClick={() => void dialog.send({ type: "button", button: b.data }, b.label)}
>
{b.label}
</button>
))}
</div>
)}
<div ref={endRef} />
<form className="composer" onSubmit={send}>
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} />
</button>
</form>
</div>
</AppShell>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
// H1 / H2 · Профиль и мои данные
export function Profile() {
const t = ru.profile;
const navigate = useNavigate();
const { session, signOut } = useSession();
const dialog = useDialog();
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
if (confirm && !d.open) d.showModal();
if (!confirm && d.open) d.close();
}, [confirm]);
if (!session) return null;
const anon = session.mode === "anonymous";
const revoke = async () => {
setConfirm(false);
if (await dialog.send({ type: "button", button: "consent:revoke" }, t.revoke)) navigate("/flow");
};
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
</header>
<section className="stack stack--sm">
<div className="row-card row-card--static">
<span className="avatar avatar--lg">
<Icon name={anon ? "lock" : "user"} />
</span>
<span className="row-card__text">
<span className="row-card__sub">{t.signInMode}</span>
<span className="row-card__title">
{anon ? t.anonymous : t.emias}
{session.displayName ? ` · ${session.displayName}` : ""}
</span>
</span>
</div>
{anon && (
<button
type="button"
className="btn btn--soft"
onClick={() => {
signOut();
navigate("/emias");
}}
>
{t.connectEmias}
</button>
)}
</section>
<section className="stack stack--sm">
<h2 className="h3">{t.data}</h2>
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
<span className="tile-icon">
<Icon name="message" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.messages}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card row-card--danger" onClick={() => setConfirm(true)}>
<span className="tile-icon tile-icon--red">
<Icon name="shield" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.revoke}</span>
</span>
<Icon name="chevronRight" />
</button>
{anon && (
<button type="button" className="row-card" onClick={() => signOut({ wipe: true })}>
<span className="tile-icon">
<Icon name="trash" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.wipe}</span>
<span className="row-card__sub">{t.wipeNote}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
<button type="button" className="row-card row-card--danger" onClick={() => signOut()}>
<span className="tile-icon tile-icon--red">
<Icon name="logout" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.logout}</span>
</span>
<Icon name="chevronRight" />
</button>
</section>
</div>
<dialog ref={ref} className="modal" aria-labelledby="revoke-title" onClose={() => setConfirm(false)}>
<div className="modal__body">
<div className="modal__head">
<h2 id="revoke-title" className="h2">
{t.revokeTitle}
</h2>
<button type="button" className="icon-btn" onClick={() => setConfirm(false)} aria-label={t.cancel}>
<Icon name="close" />
</button>
</div>
<p className="muted">{t.revokeText}</p>
<button type="button" className="btn btn--danger" onClick={revoke}>
{t.revokeConfirm}
</button>
<button type="button" className="btn btn--soft" onClick={() => setConfirm(false)}>
{t.cancel}
</button>
</div>
</dialog>
</AppShell>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useNavigate } from "react-router-dom";
import type { TodayDose } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { humanDate, useToday } from "./useToday";
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
return (
<button type="button" className="row-card" onClick={onOpen}>
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.visit}</span>
<span className="row-card__title">{humanDate(date)}</span>
</span>
<Icon name="chevronRight" />
</button>
);
}
// C1 / C2 · Сегодня
export function Today() {
const t = ru.today;
const navigate = useNavigate();
const { session } = useSession();
const dialog = useDialog();
const { plan, failed } = useToday();
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
const waiting = classify(dialog.pending);
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
const pendingDoses = plan?.doses.filter((d) => d.status === "pending") ?? [];
const takenDoses = plan?.doses.filter((d) => d.status === "taken") ?? [];
const allDone = plan !== null && pendingDoses.length === 0 && plan.checkin_done && !hasOpenStep;
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
const visit = plan?.next_visit ? <VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} /> : null;
return (
<AppShell aside={visit}>
<div className="stack">
<header className="today-head">
<div>
<h1 className="h1">{t.title}</h1>
{plan && <p className="muted">{humanDate(plan.today)}</p>}
</div>
<button type="button" className="icon-btn icon-btn--bell" onClick={() => navigate("/messages")} aria-label={ru.nav.messages}>
<Icon name="bell" />
{dialog.unread > 0 && <span className="dot" />}
</button>
</header>
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
{hasOpenStep && (
<button type="button" className="task task--hero" onClick={() => navigate("/flow")}>
<span className="task__icon">
<Icon name="message" />
</span>
<span className="task__text">
<span className="task__title">{t.newMessage}</span>
<span className="task__sub">{dialog.pending[0]?.text.split("\n")[0].slice(0, 80)}</span>
</span>
<span className="chip chip--teal-solid">{t.open}</span>
</button>
)}
{allDone ? (
<section className="alldone">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={36} />
</span>
<h2 className="h2 center">{t.allDoneTitle}</h2>
<p className="muted center">{nextDose ? t.allDoneNext(`завтра в ${nextDose}`) : t.allDoneNoMeds}</p>
{plan && plan.prescriptions.length > 0 && (
<button type="button" className="btn btn--primary" onClick={() => navigate("/meds")}>
{t.viewMeds}
<Icon name="chevronRight" size={20} />
</button>
)}
</section>
) : (
<div className="tasks">
{pendingDoses.map((d) => (
<button
key={d.id}
type="button"
className={d.time <= now && !hasOpenStep ? "task task--hero" : "task"}
onClick={() => openDose(d)}
>
<span className="task__icon task__icon--teal">
<Icon name="pill" />
</span>
<span className="task__text">
<span className="task__title">{t.takeMed}</span>
<span className="task__sub">{d.time}</span>
</span>
{d.time <= now && <span className="chip chip--teal-solid">{t.now}</span>}
<Icon name="chevronRight" />
</button>
))}
{plan && !plan.checkin_done && (
<button type="button" className="task" onClick={() => go("menu:checkin", t.checkin)} disabled={dialog.busy}>
<span className="task__icon task__icon--teal">
<Icon name="smile" />
</span>
<span className="task__text">
<span className="task__title">{t.checkin}</span>
<span className="task__sub">{t.checkinHint}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
</div>
)}
{!allDone && (takenDoses.length > 0 || plan?.checkin_done) && (
<section className="stack stack--sm">
<h2 className="h3">{t.done}</h2>
{takenDoses.map((d) => (
<div key={d.id} className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span>
<span className="done-row__title">{t.medTaken}</span>
<span className="done-row__sub">{t.medTakenAt(d.time)}</span>
</span>
</div>
))}
{plan?.checkin_done && (
<div className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span className="done-row__title">{t.checkinDone}</span>
</div>
)}
</section>
)}
{plan?.next_visit && (
<div className="only-phone">
<VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} />
</div>
)}
{session?.mode === "anonymous" || (plan && !plan.has_doctor) ? (
<Notice>{t.noDoctor}</Notice>
) : (
<div className="quick">
<button type="button" className="quick__btn" onClick={() => go("menu:med_question", t.medQuestion)} disabled={dialog.busy}>
<Icon name="question" />
{t.medQuestion}
</button>
<button type="button" className="quick__btn" onClick={() => go("menu:contact_doctor", t.messageDoctor)} disabled={dialog.busy}>
<Icon name="message" />
{t.messageDoctor}
</button>
</div>
)}
</div>
</AppShell>
);
}
+31 -123
View File
@@ -4,142 +4,50 @@ import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
const SCALE = [1, 2, 3, 4, 5] as const; // A1 · Выбор способа входа
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>
);
}
export function Welcome() { export function Welcome() {
const navigate = useNavigate(); const navigate = useNavigate();
const t = ru.welcome; const t = ru.welcome;
return ( return (
<AppShell> <AppShell signedIn={false}>
<main className="welcome" id="main"> <div className="stack">
<div className="welcome__inner"> <header className="head">
<section className="welcome__intro" aria-labelledby="welcome-title"> <h1 className="h1">{t.title}</h1>
<h1 id="welcome-title" className="display">
{t.title}
</h1>
<p className="lead">{t.lead}</p> <p className="lead">{t.lead}</p>
</section> </header>
<section className="signin" aria-labelledby="choice-title"> <div className="entry-grid">
<h2 id="choice-title" className="signin__title"> <button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
{t.choose} <span className="entry__top">
</h2> <span className="tile-icon tile-icon--teal">
<Icon name="docPlus" size={26} />
<article className="option option--emias">
<div className="option__head">
<span className="option__icon">
<Icon name="doctor" size={22} />
</span> </span>
<h3 className="option__title">{t.emias.title}</h3> <span className="chip chip--teal">{ru.common.recommended}</span>
<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> </span>
<h3 className="option__title">{t.anonymous.title}</h3> <span className="entry__title">
</div> {t.emias.title}
<Facts items={t.anonymous.facts} /> <Icon name="chevronRight" />
<button type="button" className="btn btn--secondary btn--block" onClick={() => navigate("/anonymous")}> </span>
{t.anonymous.action} <span className="entry__text">{t.emias.text}</span>
</button> </button>
</article>
</section>
<div className="welcome__aside"> <button type="button" className="entry" onClick={() => navigate("/anonymous")}>
<DayPreview /> <span className="entry__top">
<p className="disclaimer">{t.disclaimer}</p> <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> </div>
<p className="muted small">{t.disclaimer}</p>
</div> </div>
</main>
</AppShell> </AppShell>
); );
} }
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { TodayPlan } from "../api/types";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
/** The patient's plan for today; refreshed whenever the dialog moves (a dose answered, a check-in done). */
export function useToday(): { plan: TodayPlan | null; failed: boolean } {
const { session } = useSession();
const { pending } = useDialog();
const [plan, setPlan] = useState<TodayPlan | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!session) return;
let alive = true;
api
.today(session.token)
.then((p) => {
if (!alive) return;
setPlan(p);
setFailed(false);
})
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [session, pending]);
return { plan, failed };
}
const MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"];
const WEEKDAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
/** "12 марта, вторник" — from an ISO date, no timezone shift. */
export function humanDate(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return `${d} ${MONTHS[m - 1]}, ${WEEKDAYS[wd]}`;
}
+4 -3
View File
@@ -6,7 +6,8 @@ import { sessionStore, type Session } from "./store";
interface SessionApi { interface SessionApi {
session: Session | null; session: Session | null;
signIn: (auth: AuthResult) => void; 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); const Ctx = createContext<SessionApi | null>(null);
@@ -20,9 +21,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
setSession(next); setSession(next);
}, []); }, []);
const signOut = useCallback(() => { const signOut = useCallback((opts?: { wipe?: boolean }) => {
setSession((current) => { setSession((current) => {
sessionStore.clear(current); sessionStore.clear(current, opts?.wipe ?? current?.mode === "anonymous");
return null; return null;
}); });
}, []); }, []);
+14 -6
View File
@@ -12,9 +12,16 @@ export interface Session {
} }
export type ChatEntry = export type ChatEntry =
| { id: string; from: "assistant"; reply: Reply; answered: boolean } | { id: string; from: "assistant"; reply: Reply }
| { id: string; from: "patient"; text: string }; | { 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 { interface KeyValue {
get(key: string): string | null; get(key: string): string | null;
set(key: string, value: string): void; set(key: string, value: string): void;
@@ -46,7 +53,8 @@ const browserStorage: KeyValue = {
}; };
const SESSION_KEY = "ptsr.session.v1"; 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 { function read<T>(storage: KeyValue, key: string): T | null {
const raw = storage.get(key); const raw = storage.get(key);
@@ -62,12 +70,12 @@ export function createSessionStore(storage: KeyValue = browserStorage) {
return { return {
load: () => read<Session>(storage, SESSION_KEY), load: () => read<Session>(storage, SESSION_KEY),
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)), save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
clear: (s: Session | null) => { clear: (s: Session | null, wipeDialog = true) => {
storage.remove(SESSION_KEY); 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)) ?? [], loadDialog: (token: string): DialogSnapshot => ({ ...EMPTY, ...read<DialogSnapshot>(storage, dialogKey(token)) }),
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)), saveDialog: (token: string, snap: DialogSnapshot) => storage.set(dialogKey(token), JSON.stringify(snap)),
}; };
} }
+1499 -1010
View File
File diff suppressed because it is too large Load Diff
+46 -55
View File
@@ -1,64 +1,57 @@
/* /*
* Design tokens — the single source of colours, type and spacing. * Design tokens — single source of colours, type and spacing (docs/mock, series 2).
* When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a * Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object.
* theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
*/ */
:root { :root {
/* Base: cool ink on cool white. */ /* Ink & surfaces */
--c-ink: #13233a; --c-navy: #0f2747; /* primary buttons, headings, desktop sidebar */
--c-text: #13233a; --c-navy-hover: #0a1d37;
--c-text-2: #46566b; --c-navy-soft: #e8eef6;
--c-text-3: #6b7a8e; --c-text: #13294b;
--c-page: #f4f7fb; --c-text-2: #4d5d73;
--c-text-3: #7a889b;
--c-page: #ffffff;
--c-page-desk: #f4f7fb; /* desktop canvas behind white panels */
--c-surface: #ffffff; --c-surface: #ffffff;
--c-band: #e9f0fb; /* solid tint behind the welcome hero */ --c-tile: #f3f7fb; /* soft card fill */
--c-border: #dbe3ee; --c-border: #e2e9f1;
--c-border-strong: #b3c0d1; --c-border-strong: #c9d4e1;
/* Care tracks: one colour per thing the service does. The same colour marks the track everywhere /* Selection & positive: teal */
(welcome preview, chat questions), so colour carries meaning, never decoration. */ --c-teal: #17a08f;
--c-meds: #0e8c7f; --c-teal-strong: #0e7e71;
--c-meds-soft: #e2f4f1; --c-teal-soft: #e3f5f2;
--c-mood: #e79a0c;
--c-mood-soft: #fdf1da;
--c-mood-ink: #8a5800; /* text on mood-soft (AA) */
--c-doctor: #2350c8;
--c-doctor-soft: #e7eefc;
/* Action colour = the doctor/ЕМИАС blue. */ /* Info: blue */
--c-primary: #2350c8; --c-blue: #2f7fdb;
--c-primary-hover: #1c43ac; --c-blue-soft: #eaf3fd;
--c-primary-active: #163689;
--c-primary-soft: #e7eefc;
/* Mood scale 1 → 5 (heavy → good); used only for the scale. */ /* Help & crisis: red */
--c-scale-1: #d9485f; --c-red: #e2383b;
--c-scale-2: #ef8a3c; --c-red-strong: #c62b2e;
--c-scale-3: #f2c230; --c-red-soft: #fdeded;
--c-scale-4: #7cc35b; --c-red-line: #f7d3d3;
--c-scale-5: #2fa36b;
--c-danger: #c8283a; /* Warning */
--c-danger-hover: #a91f2f; --c-amber: #e69a12;
--c-danger-soft: #fdebed; --c-amber-soft: #fff4dd;
--c-success: #1e8a55;
--c-warning-soft: #fff4dc;
--c-warning-border: #e7b448;
--c-focus: #3b82f6;
/* 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; --font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
--fs-12: 0.75rem; --fs-12: 0.75rem;
--fs-13: 0.8125rem;
--fs-14: 0.875rem; --fs-14: 0.875rem;
--fs-16: 1rem; --fs-16: 1rem;
--fs-18: 1.125rem; --fs-18: 1.125rem;
--fs-22: 1.375rem; --fs-20: 1.25rem;
--fs-24: 1.5rem;
--fs-28: 1.75rem; --fs-28: 1.75rem;
--fs-40: 2.5rem; --fs-34: 2.125rem;
--lh-tight: 1.15; --fs-48: 3rem;
--lh-text: 1.5;
/* Spacing: 4 px grid. */ /* Spacing: 4 px grid */
--s-1: 4px; --s-1: 4px;
--s-2: 8px; --s-2: 8px;
--s-3: 12px; --s-3: 12px;
@@ -68,19 +61,17 @@
--s-8: 32px; --s-8: 32px;
--s-10: 40px; --s-10: 40px;
--s-12: 48px; --s-12: 48px;
--s-16: 64px;
/* Radius hierarchy: containers > controls > fields > chips. */ /* Radii: tiles > buttons > chips */
--r-card: 14px; --r-tile: 16px;
--r-control: 10px; --r-btn: 12px;
--r-field: 8px; --r-chip: 999px;
--r-chip: 6px;
--tap: 48px; /* minimum touch target */ --tap: 52px;
--header-h: 64px; --nav-h: 64px;
--content-max: 1160px; --sidebar-w: 232px;
--rail-w: 300px;
/* Safe areas (notch, home indicator) for the mobile shell. */
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px); --safe-left: env(safe-area-inset-left, 0px);