release: dev → main (сдача хакатона) #106
+134
-1
@@ -16,14 +16,19 @@ issued by the server instead of handing out the pseudonymous token itself.
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api import fixtures
|
||||
from app.core import clock
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||||
from app.db.identity import TOKEN_LEN
|
||||
from app.db.session import get_clinical_session
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||||
"""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)
|
||||
if persona is None:
|
||||
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])
|
||||
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")
|
||||
|
||||
|
||||
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])
|
||||
async def crisis_resources() -> list[CrisisResource]:
|
||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -9,6 +10,7 @@ from app.api import fixtures
|
||||
from app.core import clock
|
||||
from app.db import identity_repo, repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.db.clinical import Dose, Prescription
|
||||
from app.main import app
|
||||
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
@@ -81,6 +83,37 @@ async def test_emias_demo_persona(client: AsyncClient) -> None:
|
||||
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:
|
||||
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
|
||||
assert "112" in phones
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.vite/
|
||||
|
||||
+12
-1
@@ -30,9 +30,20 @@ src/theme/tokens.css цвета, шрифт, отступы — единств
|
||||
src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
|
||||
src/api/ типы-зеркала контрактов бэкенда и клиент
|
||||
src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain
|
||||
src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat
|
||||
src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts)
|
||||
src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4),
|
||||
«Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1
|
||||
```
|
||||
|
||||
### Как устроены экраны
|
||||
|
||||
Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его
|
||||
как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст,
|
||||
напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда.
|
||||
|
||||
Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю,
|
||||
следующий визит, время по демо-часам).
|
||||
|
||||
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
|
||||
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types";
|
||||
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types";
|
||||
|
||||
// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
|
||||
const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
|
||||
@@ -35,6 +35,7 @@ export const api = {
|
||||
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
|
||||
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
|
||||
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
|
||||
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
|
||||
sendEvent: async (token: string, event: DialogEvent) =>
|
||||
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
|
||||
outbox: async (token: string) =>
|
||||
|
||||
@@ -39,3 +39,30 @@ export interface DemoPersona {
|
||||
therapy_day: number;
|
||||
scenario: string | null;
|
||||
}
|
||||
|
||||
export type DoseStatus = "taken" | "missed" | "skipped" | "pending";
|
||||
|
||||
export interface TodayDose {
|
||||
id: number;
|
||||
drug: string;
|
||||
dose: string;
|
||||
time: string;
|
||||
status: DoseStatus;
|
||||
}
|
||||
|
||||
export interface TodayPrescription {
|
||||
drug: string;
|
||||
dose: string;
|
||||
times: string[];
|
||||
week: { day: string; status: "taken" | "missed" | "pending" | "none" }[];
|
||||
}
|
||||
|
||||
export interface TodayPlan {
|
||||
today: string;
|
||||
now: string;
|
||||
has_doctor: boolean;
|
||||
checkin_done: boolean;
|
||||
next_visit: string | null;
|
||||
doses: TodayDose[];
|
||||
prescriptions: TodayPrescription[];
|
||||
}
|
||||
|
||||
+159
-47
@@ -1,64 +1,176 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
|
||||
import { ru } from "../content/ru";
|
||||
import { HelpSheet } from "./HelpSheet";
|
||||
import { Icon, LogoMark } from "./Icon";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { HotlineCard, useHelp } from "./HelpSheet";
|
||||
import { Icon, LogoMark, type IconName } from "./Icon";
|
||||
|
||||
export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
/*
|
||||
* Layout from docs/mock (series 2).
|
||||
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
|
||||
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel)
|
||||
*/
|
||||
|
||||
const TABS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/today", label: ru.nav.today, icon: "home" },
|
||||
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
|
||||
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
|
||||
{ to: "/profile", label: ru.nav.profile, icon: "user" },
|
||||
];
|
||||
|
||||
/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */
|
||||
export function EmergencyBlock() {
|
||||
const { resources, open } = useHelp();
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="topbar__inner">
|
||||
<Link to="/" className="brand" aria-label={ru.product.name}>
|
||||
<LogoMark />
|
||||
<span className="brand__text">
|
||||
<span className="brand__name">{ru.product.name}</span>
|
||||
<span className="brand__tagline">{ru.product.tagline}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}>
|
||||
<Icon name="phone" size={18} />
|
||||
<span>{ru.help.button}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="sosblock" aria-label={ru.help.block}>
|
||||
<button type="button" className="sosblock__head" onClick={open}>
|
||||
<span className="sosblock__icon">
|
||||
<Icon name="phone" size={18} />
|
||||
</span>
|
||||
<span>{ru.help.block}</span>
|
||||
<Icon name="chevronRight" size={20} />
|
||||
</button>
|
||||
<div className="sosblock__lines">
|
||||
{resources.slice(0, 2).map((r) => (
|
||||
<HotlineCard key={r.phone} r={r} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{children}
|
||||
|
||||
{footer && (
|
||||
<footer className="footer">
|
||||
<div className="footer__inner">
|
||||
<p className="footer__line">
|
||||
<Icon name="lock" size={16} />
|
||||
<span>{ru.footer.law}</span>
|
||||
</p>
|
||||
<p className="footer__line footer__line--muted">{ru.footer.prototype}</p>
|
||||
</div>
|
||||
</footer>
|
||||
function Sidebar({ signedIn }: { signedIn: boolean }) {
|
||||
const { open } = useHelp();
|
||||
const { unread } = useDialog();
|
||||
return (
|
||||
<nav className="sidebar" aria-label="Разделы">
|
||||
<Link to={signedIn ? "/today" : "/"} className="sidebar__brand">
|
||||
<LogoMark />
|
||||
<span>
|
||||
<span className="sidebar__name">{ru.product.name}</span>
|
||||
<span className="sidebar__tag">{ru.product.tagline}</span>
|
||||
</span>
|
||||
</Link>
|
||||
{signedIn ? (
|
||||
<ul className="sidebar__list">
|
||||
{TABS.map((t) => (
|
||||
<li key={t.to}>
|
||||
<NavLink to={t.to} className="sidebar__link">
|
||||
<Icon name={t.icon} />
|
||||
{t.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NavLink to="/messages" className="sidebar__link">
|
||||
<Icon name="message" />
|
||||
{ru.nav.messages}
|
||||
{unread > 0 && <span className="dot" aria-label="новое" />}
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="sidebar__list">
|
||||
<li>
|
||||
<NavLink to="/" end className="sidebar__link">
|
||||
<Icon name="home" />
|
||||
{ru.nav.signIn}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" className="sidebar__link" onClick={open}>
|
||||
<Icon name="question" />
|
||||
{ru.nav.help}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
<HelpSheet open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
function BottomNav() {
|
||||
return (
|
||||
<nav className="tabbar" aria-label="Разделы">
|
||||
{TABS.map((t) => (
|
||||
<NavLink key={t.to} to={t.to} className="tabbar__item">
|
||||
<Icon name={t.icon} />
|
||||
<span>{t.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ aside }: { aside?: ReactNode }) {
|
||||
const { resources, open } = useHelp();
|
||||
return (
|
||||
<aside className="rail">
|
||||
{aside}
|
||||
<section className="railsos" aria-label={ru.help.block}>
|
||||
<button type="button" className="railsos__head" onClick={open}>
|
||||
<Icon name="phone" size={18} />
|
||||
<span>{ru.help.block}</span>
|
||||
</button>
|
||||
{resources.map((r) => (
|
||||
<HotlineCard key={r.phone} r={r} size="sm" />
|
||||
))}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
signedIn = true,
|
||||
aside,
|
||||
focus = false,
|
||||
sos = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
signedIn?: boolean;
|
||||
aside?: ReactNode;
|
||||
/** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */
|
||||
focus?: boolean;
|
||||
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
|
||||
sos?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
|
||||
<Sidebar signedIn={signedIn} />
|
||||
<div className="app__main">
|
||||
<main className="screen" id="main">
|
||||
{children}
|
||||
{sos && <EmergencyBlock />}
|
||||
</main>
|
||||
<Rail aside={aside} />
|
||||
</div>
|
||||
{signedIn && !focus && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackLink({ to, children }: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link to={to} className="back-link">
|
||||
<Icon name="arrowLeft" size={18} />
|
||||
<span>{children}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
|
||||
export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
|
||||
const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
|
||||
return (
|
||||
<div className={`notice notice--${tone}`} role="note">
|
||||
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
|
||||
<p>{children}</p>
|
||||
<Icon name={icon} />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
{onBack && (
|
||||
<button type="button" className="icon-btn" onClick={onBack} aria-label={ru.common.back}>
|
||||
<Icon name="arrowLeft" />
|
||||
</button>
|
||||
)}
|
||||
{title && <span className="topbar__title">{title}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { CrisisResource } from "../api/types";
|
||||
import { ru } from "../content/ru";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
// 112 is shown even when the API is unreachable: the help sheet must never be empty.
|
||||
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }];
|
||||
// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»).
|
||||
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }];
|
||||
|
||||
const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
||||
export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
||||
|
||||
export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
interface HelpApi {
|
||||
resources: CrisisResource[];
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<HelpApi>({ resources: FALLBACK, open: () => undefined });
|
||||
export const useHelp = () => useContext(Ctx);
|
||||
|
||||
/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */
|
||||
export function HelpProvider({ children }: { children: ReactNode }) {
|
||||
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => setOpen(true), []);
|
||||
return (
|
||||
<Ctx.Provider value={{ resources, open }}>
|
||||
{children}
|
||||
<HelpSheet open={isOpen} onClose={() => setOpen(false)} resources={resources} />
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) {
|
||||
const emergency = r.phone.replace(/\D/g, "").length <= 3;
|
||||
return (
|
||||
<a className={`hotline hotline--${size}`} href={telHref(r.phone)}>
|
||||
<span className="hotline__icon">
|
||||
<Icon name={emergency ? "phone" : "headset"} size={size === "lg" ? 26 : 20} />
|
||||
</span>
|
||||
<span className="hotline__text">
|
||||
{emergency ? (
|
||||
<>
|
||||
<span className="hotline__phone">{r.phone}</span>
|
||||
<span className="hotline__name">{r.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="hotline__name hotline__name--strong">{r.name}</span>
|
||||
<span className="hotline__phone hotline__phone--sm">{r.phone}</span>
|
||||
</>
|
||||
)}
|
||||
{r.note && size === "lg" && <span className="hotline__note">{r.note}</span>}
|
||||
</span>
|
||||
{size === "lg" && <Icon name="chevronRight" size={20} />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
@@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
|
||||
onClick={(e) => e.target === ref.current && onClose()}
|
||||
>
|
||||
<div className="sheet__body">
|
||||
<div className="sheet__head">
|
||||
<h2 id="help-title" className="sheet__title">
|
||||
{ru.help.title}
|
||||
</h2>
|
||||
<div className="sheet__bar">
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
<h2 id="help-title" className="sheet__title">
|
||||
{ru.help.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="sheet__lead">{ru.help.lead}</p>
|
||||
<ul className="hotlines">
|
||||
<div className="sos">
|
||||
<span className="sos__ring">
|
||||
<span className="sos__btn">
|
||||
<Icon name="phone" size={40} />
|
||||
</span>
|
||||
</span>
|
||||
<p className="sos__lead">{ru.help.lead}</p>
|
||||
</div>
|
||||
<div className="hotlines">
|
||||
{resources.map((r) => (
|
||||
<li key={r.phone}>
|
||||
<a className="hotline" href={telHref(r.phone)}>
|
||||
<span className="hotline__icon">
|
||||
<Icon name="phone" size={22} />
|
||||
</span>
|
||||
<span className="hotline__text">
|
||||
<span className="hotline__phone">{r.phone}</span>
|
||||
<span className="hotline__name">{r.name}</span>
|
||||
{r.note && <span className="hotline__note">{r.note}</span>}
|
||||
</span>
|
||||
<span className="hotline__call">{ru.help.call}</span>
|
||||
</a>
|
||||
</li>
|
||||
<HotlineCard key={r.phone} r={r} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="notice notice--info">
|
||||
<Icon name="info" />
|
||||
<span>{ru.help.alwaysNote}</span>
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
+104
-26
@@ -1,14 +1,37 @@
|
||||
// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers.
|
||||
// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers.
|
||||
|
||||
const paths = {
|
||||
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
||||
minus: <path d="M6 12h12" />,
|
||||
phone: (
|
||||
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
||||
home: <path d="M4 10.5L12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-6h-5v6H5a1 1 0 0 1-1-1v-8.5z" />,
|
||||
pill: (
|
||||
<>
|
||||
<rect x="3" y="8.5" width="18" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
||||
<path d="M9.5 9.5l5 5" />
|
||||
</>
|
||||
),
|
||||
doctor: (
|
||||
<>
|
||||
<circle cx="12" cy="7.5" r="3.5" />
|
||||
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
||||
<path d="M12 15.5v3m-1.5-1.5h3" />
|
||||
</>
|
||||
),
|
||||
user: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4.5 20.5c.8-3.7 3.8-6 7.5-6s6.7 2.3 7.5 6" />
|
||||
</>
|
||||
),
|
||||
bell: (
|
||||
<>
|
||||
<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 2h-15L6 16.5z" />
|
||||
<path d="M10 20.5a2 2 0 0 0 4 0" />
|
||||
</>
|
||||
),
|
||||
chevronRight: <path d="M9.5 6l6 6-6 6" />,
|
||||
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
|
||||
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />,
|
||||
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
||||
close: <path d="M6 6l12 12M18 6L6 18" />,
|
||||
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
||||
x: <path d="M7 7l10 10M17 7L7 17" />,
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -21,33 +44,88 @@ const paths = {
|
||||
<path d="M12 10v4.5m0 2.5v.5" />
|
||||
</>
|
||||
),
|
||||
phone: (
|
||||
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
||||
),
|
||||
headset: (
|
||||
<>
|
||||
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
|
||||
<rect x="3" y="13.5" width="4" height="6" rx="1.5" />
|
||||
<rect x="17" y="13.5" width="4" height="6" rx="1.5" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
|
||||
<path d="M3.5 9.5h17M8 3v4m8-4v4M8 13h1m3 0h1m3 0h1M8 16.5h1m3 0h1" />
|
||||
</>
|
||||
),
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3 2" />
|
||||
</>
|
||||
),
|
||||
pin: (
|
||||
<>
|
||||
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
|
||||
<circle cx="12" cy="10" r="2.5" />
|
||||
</>
|
||||
),
|
||||
smile: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M8.5 14c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2M9 9.8v.4m6-.4v.4" />
|
||||
</>
|
||||
),
|
||||
doc: (
|
||||
<>
|
||||
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
|
||||
<path d="M14 3.5V8h4M9 12h6m-6 3.5h6" />
|
||||
</>
|
||||
),
|
||||
docPlus: (
|
||||
<>
|
||||
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
|
||||
<path d="M14 3.5V8h4M12 11v6m-3-3h6" />
|
||||
</>
|
||||
),
|
||||
shield: (
|
||||
<>
|
||||
<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6L12 3z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
|
||||
<rect x="5" y="10.5" width="14" height="10" rx="2" />
|
||||
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
|
||||
</>
|
||||
),
|
||||
pill: (
|
||||
<>
|
||||
<rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
||||
<path d="M9.5 9.5l5 5" />
|
||||
</>
|
||||
),
|
||||
pulse: <path d="M3 12h4l2-5 4 10 2-5h6" />,
|
||||
doctor: (
|
||||
<>
|
||||
<circle cx="12" cy="7.5" r="3.5" />
|
||||
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
||||
<path d="M12 15.5v3m-1.5-1.5h3" />
|
||||
</>
|
||||
),
|
||||
close: <path d="M6 6l12 12M18 6L6 18" />,
|
||||
edit: <path d="M5 19l1-4L15.5 5.5a2 2 0 0 1 3 3L9 18l-4 1zM14 7l3 3" />,
|
||||
trash: <path d="M5 7h14M10 7V4.5h4V7m-7 0l1 12.5h8L17 7M10.5 11v5m3-5v5" />,
|
||||
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
|
||||
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
||||
heart: <path d="M12 19.5s-7.5-4.4-7.5-10a4.2 4.2 0 0 1 7.5-2.6 4.2 4.2 0 0 1 7.5 2.6c0 5.6-7.5 10-7.5 10z" />,
|
||||
lungs: (
|
||||
<>
|
||||
<path d="M12 4v8m0 0c-1 1.2-2.3 2-3.5 2M12 12c1 1.2 2.3 2 3.5 2" />
|
||||
<path d="M8.5 7.5C6 8 4 11.5 4 16c0 2 1 3.5 3 3.5s3-1.5 3-4V9M15.5 7.5C18 8 20 11.5 20 16c0 2-1 3.5-3 3.5s-3-1.5-3-4V9" />
|
||||
</>
|
||||
),
|
||||
question: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M9.6 9.5a2.4 2.4 0 0 1 4.7.7c0 1.6-2.3 2-2.3 3.6m0 2.7v.3" />
|
||||
</>
|
||||
),
|
||||
message: <path d="M4 5.5h16v11H9.5L5 20v-3.5H4z" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof paths;
|
||||
|
||||
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
|
||||
export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -55,7 +133,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role={label ? "img" : undefined}
|
||||
@@ -71,7 +149,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
|
||||
export function LogoMark({ size = 32 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
||||
<rect width="32" height="32" rx="6" fill="var(--c-primary)" />
|
||||
<rect width="32" height="32" rx="9" fill="var(--c-teal)" />
|
||||
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
+128
-63
@@ -1,100 +1,165 @@
|
||||
/*
|
||||
* All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/
|
||||
* for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here.
|
||||
* All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales,
|
||||
* reminders — comes from the engine (config/texts/) and is NOT duplicated here.
|
||||
*/
|
||||
export const ru = {
|
||||
product: {
|
||||
name: "Сопровождение пациента",
|
||||
tagline: "между визитами к врачу",
|
||||
prototype: "Прототип · синтетические данные",
|
||||
},
|
||||
nav: {
|
||||
today: "Сегодня",
|
||||
meds: "Лекарства",
|
||||
doctor: "Врач",
|
||||
profile: "Профиль",
|
||||
messages: "Сообщения",
|
||||
signIn: "Вход",
|
||||
help: "Помощь",
|
||||
},
|
||||
help: {
|
||||
button: "Нужна помощь",
|
||||
title: "Если вам сейчас очень тяжело",
|
||||
lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:",
|
||||
block: "Экстренная помощь",
|
||||
title: "Экстренная помощь",
|
||||
lead: "Если вам нужна срочная помощь, позвоните сразу.",
|
||||
call: "Позвонить",
|
||||
close: "Закрыть",
|
||||
fallbackName: "Экстренные службы",
|
||||
fallbackNote: "если есть угроза жизни прямо сейчас",
|
||||
alwaysNote: "Вы не одни. Помощь доступна круглосуточно.",
|
||||
fallbackName: "Единый номер экстренных служб",
|
||||
fallbackNote: "",
|
||||
},
|
||||
common: {
|
||||
back: "Назад",
|
||||
next: "Далее",
|
||||
skip: "Пропустить",
|
||||
send: "Отправить",
|
||||
retry: "Повторить",
|
||||
toToday: "Вернуться на сегодня",
|
||||
recommended: "Рекомендуем",
|
||||
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
|
||||
},
|
||||
welcome: {
|
||||
title: "Лечение продолжается и между приёмами",
|
||||
lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.",
|
||||
day: {
|
||||
caption: "Обычный день с сервисом",
|
||||
meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" },
|
||||
mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" },
|
||||
doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" },
|
||||
},
|
||||
choose: "Как вы хотите войти?",
|
||||
title: "Как вы хотите войти в приложение?",
|
||||
lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
|
||||
emias: {
|
||||
title: "Через ЕМИАС",
|
||||
badge: "Рекомендуем",
|
||||
facts: [
|
||||
{ ok: true, text: "Назначения врача подгружаются автоматически" },
|
||||
{ ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" },
|
||||
{ ok: true, text: "История доступна с любого устройства" },
|
||||
],
|
||||
action: "Войти через ЕМИАС",
|
||||
text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
|
||||
},
|
||||
anonymous: {
|
||||
title: "Анонимно",
|
||||
facts: [
|
||||
{ ok: true, text: "Не нужны имя, полис и телефон" },
|
||||
{ ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" },
|
||||
{ ok: false, text: "Врач не видит ваши ответы и не получает сигналов" },
|
||||
{ ok: false, text: "История доступна только на этом устройстве" },
|
||||
],
|
||||
action: "Продолжить анонимно",
|
||||
text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
|
||||
},
|
||||
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
|
||||
},
|
||||
emias: {
|
||||
back: "Способы входа",
|
||||
title: "Вход через ЕМИАС",
|
||||
demoNotice:
|
||||
"Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.",
|
||||
codeLabel: "Код подключения от врача",
|
||||
codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.",
|
||||
title: "Введите код от врача",
|
||||
lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.",
|
||||
demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.",
|
||||
codeLabel: "Код подключения",
|
||||
codePlaceholder: "Код из памятки",
|
||||
submit: "Войти",
|
||||
submit: "Продолжить",
|
||||
submitting: "Проверяем код…",
|
||||
codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.",
|
||||
networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.",
|
||||
codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.",
|
||||
or: "или",
|
||||
demoTitle: "Демо-пациенты",
|
||||
demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.",
|
||||
demoLead: "Нет кода? Войдите под синтетической учётной записью.",
|
||||
demoDay: (day: number) => `${day}-й день терапии`,
|
||||
demoAction: "Войти",
|
||||
demoEmpty: "Демо-пациенты недоступны.",
|
||||
},
|
||||
anonymous: {
|
||||
back: "Способы входа",
|
||||
title: "Анонимный вход",
|
||||
lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.",
|
||||
points: [
|
||||
"Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.",
|
||||
"Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.",
|
||||
"Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.",
|
||||
],
|
||||
warning:
|
||||
"История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.",
|
||||
submit: "Начать анонимно",
|
||||
submitting: "Создаём анонимный профиль…",
|
||||
lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
|
||||
yesTitle: "Что будет доступно:",
|
||||
yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
|
||||
noTitle: "Чего не будет:",
|
||||
no: ["Врач не увидит ответы", "История только на этом устройстве"],
|
||||
warning: "После выхода историю восстановить нельзя.",
|
||||
submit: "Продолжить анонимно",
|
||||
submitting: "Создаём профиль…",
|
||||
},
|
||||
chat: {
|
||||
modeEmias: "ЕМИАС",
|
||||
modeAnonymous: "Анонимно",
|
||||
today: {
|
||||
title: "Сегодня",
|
||||
takeMed: "Принять лекарство",
|
||||
now: "Сейчас",
|
||||
checkin: "Отметка самочувствия",
|
||||
checkinHint: "меньше минуты",
|
||||
done: "Уже выполнено",
|
||||
medTaken: "Лекарство",
|
||||
medTakenAt: (time: string) => `принято, ${time}`,
|
||||
checkinDone: "Самочувствие отмечено",
|
||||
allDoneTitle: "На сегодня всё сделано",
|
||||
allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`,
|
||||
allDoneNoMeds: "Следующая отметка самочувствия — завтра",
|
||||
viewMeds: "Посмотреть лекарства",
|
||||
visit: "Следующий визит к врачу",
|
||||
medQuestion: "Вопрос о лекарстве",
|
||||
messageDoctor: "Сообщение врачу",
|
||||
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.",
|
||||
newMessage: "Новое сообщение",
|
||||
open: "Открыть",
|
||||
},
|
||||
meds: {
|
||||
title: "Моя схема приёма",
|
||||
lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.",
|
||||
byDoctor: "По назначению врача",
|
||||
tablet: "1 приём",
|
||||
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
|
||||
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
|
||||
empty: "Назначения появятся здесь после входа через ЕМИАС.",
|
||||
question: "Вопрос о лекарстве",
|
||||
reminderTitle: "Время принять препарат",
|
||||
reminderLead: "По назначению врача",
|
||||
taken: "Принял(а)",
|
||||
skip: "Пропущу",
|
||||
sideEffects: "Есть побочные эффекты",
|
||||
},
|
||||
doctor: {
|
||||
title: "Врач",
|
||||
visit: "Следующий визит",
|
||||
visitNone: "Дата следующего визита пока не назначена",
|
||||
attending: "Ваш лечащий врач",
|
||||
write: "Написать врачу",
|
||||
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.",
|
||||
earlier: "Записаться к врачу раньше",
|
||||
plan: "Мой план безопасности",
|
||||
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.",
|
||||
},
|
||||
profile: {
|
||||
title: "Профиль",
|
||||
signInMode: "Способ входа",
|
||||
emias: "ЕМИАС",
|
||||
anonymous: "Анонимно",
|
||||
connectEmias: "Подключить ЕМИАС",
|
||||
data: "Мои данные",
|
||||
revoke: "Отозвать согласие",
|
||||
revokeTitle: "Отозвать согласие?",
|
||||
revokeText: "Ваши ответы больше не будут обрабатываться в приложении.",
|
||||
revokeConfirm: "Да, отозвать",
|
||||
cancel: "Отмена",
|
||||
wipe: "Удалить данные с устройства",
|
||||
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
|
||||
logout: "Выйти",
|
||||
logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.",
|
||||
messages: "Сообщения",
|
||||
},
|
||||
flow: {
|
||||
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
|
||||
period: "За последний месяц",
|
||||
textPlaceholder: "Напишите несколько слов",
|
||||
sending: "Отправляем…",
|
||||
doneTitle: "Готово",
|
||||
doneLead: "Врач увидит отметку.",
|
||||
breathe: "Вдох на 4 — выдох на 6",
|
||||
breatheIn: "Вдох",
|
||||
breatheOut: "Выдох",
|
||||
openPlan: "Открыть мой план безопасности",
|
||||
supportTitle: "Вы не одни",
|
||||
exit: "К главной",
|
||||
planTitle: "Мой план безопасности",
|
||||
consentTitle: "Согласие на обработку данных о здоровье",
|
||||
},
|
||||
messages: {
|
||||
title: "Сообщения",
|
||||
inputLabel: "Сообщение",
|
||||
inputPlaceholder: "Напишите сообщение",
|
||||
send: "Отправить",
|
||||
loading: "Подключаемся…",
|
||||
sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.",
|
||||
you: "Вы",
|
||||
assistant: "Помощник",
|
||||
crisisLabel: "Важно",
|
||||
empty: "Здесь будут сообщения помощника.",
|
||||
},
|
||||
footer: {
|
||||
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { DialogEvent, Reply } from "../api/types";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
|
||||
|
||||
/*
|
||||
* One conversation with the dialog engine per signed-in patient, shared by every screen:
|
||||
* focus screens render `pending` (the latest unanswered batch), «Сообщения» renders `entries`.
|
||||
* The engine owns the logic (consent, onboarding, scales, check-ins, meds, crisis) — exactly as in Telegram.
|
||||
*/
|
||||
|
||||
const OUTBOX_POLL_MS = 6000;
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
|
||||
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
|
||||
|
||||
interface DialogApi extends DialogSnapshot {
|
||||
busy: boolean;
|
||||
error: boolean;
|
||||
started: boolean;
|
||||
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
|
||||
send: (event: DialogEvent, echo?: string) => Promise<boolean>;
|
||||
start: () => Promise<boolean>;
|
||||
markRead: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<DialogApi | null>(null);
|
||||
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const { session } = useSession();
|
||||
const token = session?.token ?? null;
|
||||
const [owner, setOwner] = useState(token);
|
||||
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
|
||||
// Switching accounts: reset to that token's snapshot during render, so it is never saved under another token.
|
||||
if (owner !== token) {
|
||||
setOwner(token);
|
||||
setSnap(token ? sessionStore.loadDialog(token) : EMPTY);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token && owner === token) sessionStore.saveDialog(token, snap);
|
||||
}, [token, owner, snap]);
|
||||
|
||||
const send = useCallback(
|
||||
async (event: DialogEvent, echo?: string) => {
|
||||
const t = tokenRef.current;
|
||||
if (!t) return false;
|
||||
setBusy(true);
|
||||
setError(false);
|
||||
try {
|
||||
const replies = await api.sendEvent(t, event);
|
||||
setSnap((prev) => ({
|
||||
entries: [
|
||||
...prev.entries,
|
||||
...(echo ? [{ id: newId(), from: "patient" as const, text: echo }] : []),
|
||||
...asEntries(replies),
|
||||
],
|
||||
pending: replies,
|
||||
unread: 0,
|
||||
}));
|
||||
return true;
|
||||
} catch {
|
||||
setError(true);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const start = useCallback(() => send({ type: "start" }), [send]);
|
||||
const markRead = useCallback(() => setSnap((prev) => (prev.unread ? { ...prev, unread: 0 } : prev)), []);
|
||||
|
||||
// System-initiated messages (reminders, check-ins, scales) arrive through the outbox.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const timer = window.setInterval(() => {
|
||||
api
|
||||
.outbox(token)
|
||||
.then((replies) => {
|
||||
if (!replies.length || tokenRef.current !== token) return;
|
||||
setSnap((prev) => ({
|
||||
entries: [...prev.entries, ...asEntries(replies)],
|
||||
pending: replies,
|
||||
unread: prev.unread + replies.length,
|
||||
}));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, OUTBOX_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo<DialogApi>(
|
||||
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
|
||||
[snap, busy, error, send, start, markRead],
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useDialog(): DialogApi {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error("useDialog outside DialogProvider");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { DialogButton, Reply } from "../api/types";
|
||||
|
||||
/*
|
||||
* Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is
|
||||
* asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen.
|
||||
*/
|
||||
|
||||
export type Step =
|
||||
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
|
||||
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
|
||||
| { kind: "crisis"; text: string; buttons: DialogButton[] }
|
||||
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
|
||||
| { kind: "number"; lead: string[]; title: string; options: DialogButton[] }
|
||||
| { kind: "actions"; lead: string[]; title: string; options: DialogButton[] }
|
||||
| { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress }
|
||||
| { kind: "text"; lead: string[]; title: string; skip?: DialogButton };
|
||||
|
||||
export interface Progress {
|
||||
n: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own.
|
||||
const MENU_PROMPT = "Чем могу помочь?";
|
||||
// config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}"
|
||||
const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/;
|
||||
|
||||
const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:"));
|
||||
const flat = (r: Reply) => r.buttons.flat();
|
||||
const paragraphs = (text: string) =>
|
||||
text
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** Splits a reply into a title (the actual question) and lead paragraphs above it. */
|
||||
function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } {
|
||||
const parts = texts.flatMap(paragraphs);
|
||||
const last = parts.pop() ?? "";
|
||||
const m = SCALE_ITEM.exec(last);
|
||||
if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } };
|
||||
// A long last paragraph is body text, not a heading.
|
||||
if (last.length > 220) return { lead: [...parts, last], title: "" };
|
||||
return { lead: parts, title: last };
|
||||
}
|
||||
|
||||
export function classify(batch: Reply[]): Step | null {
|
||||
if (batch.length === 0) return null;
|
||||
|
||||
const crisis = batch.find((r) => r.kind === "crisis");
|
||||
if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) };
|
||||
|
||||
if (batch.some(isMenu)) {
|
||||
const notes = batch
|
||||
.filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT)
|
||||
.map((r) => r.text.trim())
|
||||
.filter((t) => t && t !== MENU_PROMPT);
|
||||
return { kind: "idle", notes };
|
||||
}
|
||||
|
||||
const plan = batch.find((r) => r.kind === "safety_plan");
|
||||
if (plan) {
|
||||
const [title, ...rest] = plan.text.split("\n").map((l) => l.trim());
|
||||
const lead = batch.filter((r) => r !== plan).map((r) => r.text);
|
||||
return {
|
||||
kind: "plan",
|
||||
lead,
|
||||
title,
|
||||
lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")),
|
||||
buttons: batch.flatMap(flat),
|
||||
};
|
||||
}
|
||||
|
||||
// The question is the last reply with buttons; everything before it is context.
|
||||
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
|
||||
const texts = batch.map((r) => r.text);
|
||||
if (qIndex === -1) {
|
||||
const { lead, title } = titled(texts);
|
||||
return { kind: "text", lead, title };
|
||||
}
|
||||
const options = flat(batch[qIndex]);
|
||||
const head = titled(texts.slice(0, qIndex + 1));
|
||||
const tail = texts.slice(qIndex + 1); // rare: info after the question
|
||||
const lead = [...head.lead, ...tail];
|
||||
|
||||
if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options };
|
||||
if (options.every((b) => b.data.endsWith(":skip")))
|
||||
return { kind: "text", lead, title: head.title, skip: options[0] };
|
||||
if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim())))
|
||||
return { kind: "number", lead, title: head.title, options };
|
||||
if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data)))
|
||||
return { kind: "actions", lead, title: head.title, options };
|
||||
return { kind: "choice", lead, title: head.title, options, progress: head.progress };
|
||||
}
|
||||
|
||||
/** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */
|
||||
export function lines(text: string): { list: boolean; text: string }[] {
|
||||
const out: { list: boolean; text: string }[] = [];
|
||||
for (const raw of text.split("\n")) {
|
||||
const l = raw.trim();
|
||||
if (!l) continue;
|
||||
if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") });
|
||||
else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text))
|
||||
out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` };
|
||||
else out.push({ list: false, text: l });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+30
-7
@@ -1,28 +1,47 @@
|
||||
import "@fontsource/golos-text/400.css";
|
||||
import "@fontsource/golos-text/500.css";
|
||||
import "@fontsource/golos-text/600.css";
|
||||
import "@fontsource/golos-text/700.css";
|
||||
import "./theme/tokens.css";
|
||||
import "./theme/global.css";
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { HelpProvider } from "./components/HelpSheet";
|
||||
import { DialogProvider } from "./dialog/DialogContext";
|
||||
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
|
||||
import { Chat } from "./screens/Chat";
|
||||
import { Doctor } from "./screens/Doctor";
|
||||
import { EmiasSignIn } from "./screens/EmiasSignIn";
|
||||
import { Flow } from "./screens/Flow";
|
||||
import { DoseReminder, Meds } from "./screens/Meds";
|
||||
import { Messages } from "./screens/Messages";
|
||||
import { Profile } from "./screens/Profile";
|
||||
import { Today } from "./screens/Today";
|
||||
import { Welcome } from "./screens/Welcome";
|
||||
import { SessionProvider, useSession } from "./session/SessionContext";
|
||||
|
||||
function Private({ children }: { children: ReactNode }) {
|
||||
const { session } = useSession();
|
||||
return session ? <>{children}</> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
|
||||
function AppRoutes() {
|
||||
const { session } = useSession();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={session ? <Navigate to="/chat" replace /> : <Welcome />} />
|
||||
<Route path="/" element={session ? <Navigate to="/today" replace /> : <Welcome />} />
|
||||
<Route path="/emias" element={<EmiasSignIn />} />
|
||||
<Route path="/anonymous" element={<AnonymousSignIn />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/flow" element={<Private><Flow /></Private>} />
|
||||
<Route path="/today" element={<Private><Today /></Private>} />
|
||||
<Route path="/dose/:id" element={<Private><DoseReminder /></Private>} />
|
||||
<Route path="/meds" element={<Private><Meds /></Private>} />
|
||||
<Route path="/doctor" element={<Private><Doctor /></Private>} />
|
||||
<Route path="/profile" element={<Private><Profile /></Private>} />
|
||||
<Route path="/messages" element={<Private><Messages /></Private>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -31,9 +50,13 @@ function AppRoutes() {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<SessionProvider>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
<DialogProvider>
|
||||
<HelpProvider>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
</HelpProvider>
|
||||
</DialogProvider>
|
||||
</SessionProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -2,11 +2,15 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { AppShell, BackLink, Notice } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
|
||||
const NO_ICONS: IconName[] = ["user", "doc"];
|
||||
|
||||
// A3 · Анонимный вход
|
||||
export function AnonymousSignIn() {
|
||||
const t = ru.anonymous;
|
||||
const navigate = useNavigate();
|
||||
@@ -19,7 +23,7 @@ export function AnonymousSignIn() {
|
||||
setError(false);
|
||||
try {
|
||||
signIn(await api.signInAnonymous());
|
||||
navigate("/chat", { replace: true });
|
||||
navigate("/flow", { replace: true });
|
||||
} catch {
|
||||
setError(true);
|
||||
setBusy(false);
|
||||
@@ -27,35 +31,55 @@ export function AnonymousSignIn() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="page" id="main">
|
||||
<div className="narrow">
|
||||
<BackLink to="/">{t.back}</BackLink>
|
||||
<section className="panel" aria-labelledby="anon-title">
|
||||
<h1 id="anon-title" className="h2">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="lead lead--sm">{t.lead}</p>
|
||||
<ul className="facts facts--spaced">
|
||||
{t.points.map((p) => (
|
||||
<li key={p} className="fact">
|
||||
<Icon name="check" size={18} />
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Notice tone="warning">{t.warning}</Notice>
|
||||
{error && (
|
||||
<p className="field__error" role="alert">
|
||||
{ru.emias.networkError}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<AppShell signedIn={false} focus>
|
||||
<div className="stack narrow">
|
||||
<TopBar onBack={() => navigate("/")} />
|
||||
<header className="head">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</header>
|
||||
|
||||
<section className="panel panel--teal">
|
||||
<p className="panel__title">
|
||||
<span className="badge-icon badge-icon--teal">
|
||||
<Icon name="check" size={16} />
|
||||
</span>
|
||||
{t.yesTitle}
|
||||
</p>
|
||||
<ul className="iconlist">
|
||||
{t.yes.map((item, i) => (
|
||||
<li key={item}>
|
||||
<Icon name={YES_ICONS[i]} size={20} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="panel panel--red">
|
||||
<p className="panel__title">
|
||||
<span className="badge-icon badge-icon--red">
|
||||
<Icon name="x" size={16} />
|
||||
</span>
|
||||
{t.noTitle}
|
||||
</p>
|
||||
<ul className="iconlist">
|
||||
{t.no.map((item, i) => (
|
||||
<li key={item}>
|
||||
<Icon name={NO_ICONS[i]} size={20} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Notice tone="warn">{t.warning}</Notice>
|
||||
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
|
||||
<button type="button" className="btn btn--primary" onClick={start} disabled={busy}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { DialogButton, DialogEvent, Reply } from "../api/types";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { sessionStore, type ChatEntry } from "../session/store";
|
||||
|
||||
// The engine pushes reminders and check-ins to the outbox; the web channel polls it (Telegram gets them pushed).
|
||||
const OUTBOX_POLL_MS = 6000;
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
|
||||
|
||||
const assistantEntries = (replies: Reply[]): ChatEntry[] =>
|
||||
replies.map((reply) => ({ id: newId(), from: "assistant", reply, answered: false }));
|
||||
|
||||
// Phone numbers in engine texts (crisis script, help lines) become tap-to-call links.
|
||||
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|8 \(\d{3}\) \d{3}|\b11[2]\b|\b10[23]\b)/g;
|
||||
|
||||
function RichText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{text.split("\n").map((line, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line.split(PHONE_RE).map((part, j) =>
|
||||
j % 2 === 1 ? (
|
||||
<a key={j} href={`tel:${part.replace(/[^\d+]/g, "")}`} className="tel">
|
||||
{part}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={j}>{part}</Fragment>
|
||||
),
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
entry,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
entry: Extract<ChatEntry, { from: "assistant" }>;
|
||||
disabled: boolean;
|
||||
onPress: (button: DialogButton) => void;
|
||||
}) {
|
||||
const { reply } = entry;
|
||||
const special = reply.kind === "crisis" || reply.kind === "safety_plan";
|
||||
return (
|
||||
<li className={`msg msg--assistant msg--${reply.kind}`}>
|
||||
<div className="msg__bubble">
|
||||
{special && (
|
||||
<p className="msg__label">
|
||||
<Icon name="alert" size={16} />
|
||||
{ru.chat.crisisLabel}
|
||||
</p>
|
||||
)}
|
||||
<p className="msg__text">
|
||||
<RichText text={reply.text} />
|
||||
</p>
|
||||
</div>
|
||||
{reply.buttons.length > 0 && !entry.answered && (
|
||||
<div className="choices">
|
||||
{reply.buttons.map((row, r) => (
|
||||
<div key={r} className="choices__row">
|
||||
{row.map((b) => (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
className="choice"
|
||||
disabled={disabled}
|
||||
onClick={() => onPress(b)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const t = ru.chat;
|
||||
const navigate = useNavigate();
|
||||
const { session, signOut } = useSession();
|
||||
const token = session?.token ?? "";
|
||||
const [entries, setEntries] = useState<ChatEntry[]>(() => (token ? sessionStore.loadChat(token) : []));
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const started = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) sessionStore.saveChat(token, entries);
|
||||
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
|
||||
}, [entries, token]);
|
||||
|
||||
// Any patient action closes the open buttons: the engine has moved on, old choices are stale.
|
||||
const dispatch = useCallback(
|
||||
async (event: DialogEvent, optimistic: ChatEntry[]) => {
|
||||
setBusy(true);
|
||||
setError(false);
|
||||
let reopened = new Set<string>();
|
||||
setEntries((prev) => {
|
||||
reopened = new Set(prev.filter((e) => e.from === "assistant" && !e.answered).map((e) => e.id));
|
||||
return [...prev.map((e) => (reopened.has(e.id) ? { ...e, answered: true } : e)), ...optimistic];
|
||||
});
|
||||
try {
|
||||
const replies = await api.sendEvent(token, event);
|
||||
setEntries((prev) => [...prev, ...assistantEntries(replies)]);
|
||||
return true;
|
||||
} catch {
|
||||
// Roll back the optimistic patient message and re-open the buttons.
|
||||
const ids = new Set(optimistic.map((o) => o.id));
|
||||
setEntries((prev) =>
|
||||
prev.filter((e) => !ids.has(e.id)).map((e) => (reopened.has(e.id) ? { ...e, answered: false } : e)),
|
||||
);
|
||||
setError(true);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
// First visit: the engine opens the dialog (consent → onboarding) exactly as for /start in Telegram.
|
||||
useEffect(() => {
|
||||
if (!token || started.current) return;
|
||||
started.current = true;
|
||||
if (entries.length === 0) void dispatch({ type: "start" }, []);
|
||||
}, [token, entries.length, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const timer = window.setInterval(() => {
|
||||
api
|
||||
.outbox(token)
|
||||
.then((replies) => replies.length && setEntries((prev) => [...prev, ...assistantEntries(replies)]))
|
||||
.catch(() => undefined);
|
||||
}, OUTBOX_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) navigate("/", { replace: true });
|
||||
}, [session, navigate]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const sendText = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || busy) return;
|
||||
setDraft("");
|
||||
const ok = await dispatch({ type: "text", text }, [{ id: newId(), from: "patient", text }]);
|
||||
if (!ok) setDraft(text);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) void sendText();
|
||||
};
|
||||
|
||||
const press = (b: DialogButton) =>
|
||||
void dispatch({ type: "button", button: b.data }, [{ id: newId(), from: "patient", text: b.label }]);
|
||||
|
||||
return (
|
||||
<AppShell footer={false}>
|
||||
<div className="chat-bar">
|
||||
<div className="chat-bar__inner">
|
||||
<span className={`mode mode--${session.mode}`}>
|
||||
<Icon name={session.mode === "emias" ? "doctor" : "lock"} size={16} />
|
||||
{session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
|
||||
{session.displayName && <span className="mode__name">· {session.displayName}</span>}
|
||||
</span>
|
||||
{confirmLogout ? (
|
||||
<span className="logout-confirm" role="group" aria-label={t.logout}>
|
||||
<span className="logout-confirm__text">{t.logoutConfirm}</span>
|
||||
<button type="button" className="btn btn--danger btn--sm" onClick={signOut}>
|
||||
{t.logout}
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label={ru.help.close} onClick={() => setConfirmLogout(false)}>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
|
||||
>
|
||||
<Icon name="logout" size={18} />
|
||||
{t.logout}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="chat" id="main">
|
||||
<ol className="messages" aria-live="polite" aria-label={t.assistant}>
|
||||
{entries.map((entry) =>
|
||||
entry.from === "assistant" ? (
|
||||
<AssistantMessage key={entry.id} entry={entry} disabled={busy} onPress={press} />
|
||||
) : (
|
||||
<li key={entry.id} className="msg msg--patient">
|
||||
<span className="visually-hidden">{t.you}: </span>
|
||||
<div className="msg__bubble">
|
||||
<p className="msg__text">{entry.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{busy && (
|
||||
<li className="msg msg--assistant msg--typing" aria-label={t.loading}>
|
||||
<div className="msg__bubble">
|
||||
<span className="dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
<div ref={endRef} />
|
||||
</main>
|
||||
|
||||
<form className="composer" onSubmit={sendText}>
|
||||
{error && (
|
||||
<p className="composer__error" role="alert">
|
||||
{t.sendError}
|
||||
</p>
|
||||
)}
|
||||
<div className="composer__row">
|
||||
<label htmlFor="msg" className="visually-hidden">
|
||||
{t.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
className="composer__input"
|
||||
rows={1}
|
||||
value={draft}
|
||||
placeholder={t.inputPlaceholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={busy || !draft.trim()} aria-label={t.send}>
|
||||
<Icon name="send" size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppShell, Notice } from "../components/AppShell";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { humanDate, useToday } from "./useToday";
|
||||
|
||||
// F1 / F2 · Врач: следующий визит и связь с врачом (действия выполняет движок диалога)
|
||||
export function Doctor() {
|
||||
const t = ru.doctor;
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const { plan } = useToday();
|
||||
|
||||
const go = (button: string, label: string) =>
|
||||
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
|
||||
|
||||
const actions: { button: string; label: string; icon: IconName }[] = [
|
||||
{ button: "menu:contact_doctor", label: t.write, icon: "message" },
|
||||
{ button: "menu:visit", label: t.earlier, icon: "calendar" },
|
||||
{ button: "safety_plan", label: t.plan, icon: "shield" },
|
||||
];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="stack">
|
||||
<header className="head">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
</header>
|
||||
|
||||
{plan && !plan.has_doctor ? (
|
||||
<Notice>{t.noDoctor}</Notice>
|
||||
) : (
|
||||
<>
|
||||
<section className="card-list" aria-label={t.visit}>
|
||||
<div className="card-list__row">
|
||||
<span className="tile-icon tile-icon--blue">
|
||||
<Icon name="calendar" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="row-card__sub">{t.visit}</span>
|
||||
<span className="row-card__title">{plan?.next_visit ? humanDate(plan.next_visit) : t.visitNone}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-list__row">
|
||||
<span className="tile-icon tile-icon--blue">
|
||||
<Icon name="doctor" />
|
||||
</span>
|
||||
<span className="row-card__title">{t.attending}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="stack stack--sm">
|
||||
{actions.map((a) => (
|
||||
<button key={a.button} type="button" className="row-card" onClick={() => go(a.button, a.label)} disabled={dialog.busy}>
|
||||
<span className="tile-icon">
|
||||
<Icon name={a.icon} />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{a.label}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Notice>{t.writeNote}</Notice>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError, api } from "../api/client";
|
||||
import type { AuthResult, DemoPersona } from "../api/types";
|
||||
import { AppShell, BackLink, Notice } from "../components/AppShell";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
// A2 · Вход через ЕМИАС (демо: одноразовый код от врача или синтетический пациент)
|
||||
export function EmiasSignIn() {
|
||||
const t = ru.emias;
|
||||
const navigate = useNavigate();
|
||||
@@ -26,11 +27,11 @@ export function EmiasSignIn() {
|
||||
|
||||
const finish = (auth: AuthResult) => {
|
||||
signIn(auth);
|
||||
navigate("/chat", { replace: true });
|
||||
navigate("/flow", { replace: true });
|
||||
};
|
||||
|
||||
const fail = (e: unknown) => {
|
||||
setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
|
||||
setError(e instanceof ApiError && e.status === 401 ? t.codeError : ru.common.networkError);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
@@ -57,81 +58,64 @@ export function EmiasSignIn() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="page" id="main">
|
||||
<div className="narrow">
|
||||
<BackLink to="/">{t.back}</BackLink>
|
||||
<section className="panel" aria-labelledby="emias-title">
|
||||
<h1 id="emias-title" className="h2">
|
||||
{t.title}
|
||||
</h1>
|
||||
<Notice>{t.demoNotice}</Notice>
|
||||
<AppShell signedIn={false} focus>
|
||||
<div className="stack narrow">
|
||||
<TopBar onBack={() => navigate("/")} />
|
||||
<header className="head">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</header>
|
||||
|
||||
<form className="form" onSubmit={submit} noValidate>
|
||||
<div className={error ? "field field--error" : "field"}>
|
||||
<label htmlFor="code" className="field__label">
|
||||
{t.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
className="input"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t.codePlaceholder}
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
maxLength={32}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? "code-error" : "code-hint"}
|
||||
/>
|
||||
{error ? (
|
||||
<p id="code-error" className="field__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p id="code-hint" className="field__hint">
|
||||
{t.codeHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn--primary btn--block" disabled={busy || !code.trim()}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="divider" role="separator">
|
||||
<span>{t.or}</span>
|
||||
<form className="stack stack--sm" onSubmit={submit} noValidate>
|
||||
<label htmlFor="code" className="visually-hidden">
|
||||
{t.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
className={error ? "code-input is-error" : "code-input"}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t.codePlaceholder}
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
maxLength={32}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? "code-error" : undefined}
|
||||
/>
|
||||
{error && (
|
||||
<div id="code-error" role="alert">
|
||||
<Notice tone="danger">{error}</Notice>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="btn btn--primary" disabled={busy || !code.trim()}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h2 className="h3">{t.demoTitle}</h2>
|
||||
<p className="muted">{t.demoLead}</p>
|
||||
{personas === null ? null : personas.length === 0 ? (
|
||||
<p className="muted">{t.demoEmpty}</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{personas.map((p) => (
|
||||
<li key={p.token}>
|
||||
<button type="button" className="list-row" onClick={() => demo(p)} disabled={busy}>
|
||||
<span className="avatar" aria-hidden="true">
|
||||
{p.display_name.charAt(0)}
|
||||
</span>
|
||||
<span className="list-row__text">
|
||||
<span className="list-row__title">{p.display_name}</span>
|
||||
<span className="list-row__sub">{t.demoDay(p.therapy_day)}</span>
|
||||
</span>
|
||||
<span className="list-row__action">
|
||||
{t.demoAction}
|
||||
<Icon name="arrowRight" size={18} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<Notice>{t.demoNotice}</Notice>
|
||||
|
||||
<div className="divider" role="separator">
|
||||
<span>{t.or}</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section className="stack stack--sm" aria-labelledby="demo-title">
|
||||
<h2 id="demo-title" className="h3">
|
||||
{t.demoTitle}
|
||||
</h2>
|
||||
<p className="muted">{t.demoLead}</p>
|
||||
{personas?.map((p) => (
|
||||
<button key={p.token} type="button" className="row-card" onClick={() => demo(p)} disabled={busy}>
|
||||
<span className="avatar">{p.display_name.charAt(0)}</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{p.display_name}</span>
|
||||
<span className="row-card__sub">{t.demoDay(p.therapy_day)}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { DialogButton } from "../api/types";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { classify, lines, type Step } from "../dialog/step";
|
||||
|
||||
/*
|
||||
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
|
||||
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3).
|
||||
*/
|
||||
|
||||
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
|
||||
|
||||
function RichText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{text.split(PHONE_RE).map((part, i) =>
|
||||
i % 2 === 1 ? (
|
||||
<a key={i} href={telHref(part)} className="tel">
|
||||
{part}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={i}>{part}</Fragment>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ texts }: { texts: string[] }) {
|
||||
if (!texts.length) return null;
|
||||
return (
|
||||
<div className="body-text">
|
||||
{texts.map((t, i) => {
|
||||
const ls = lines(t);
|
||||
return (
|
||||
<div key={i} className="body-text__block">
|
||||
{ls.map((l, j) =>
|
||||
l.list ? (
|
||||
<ul key={j} className="bullets">
|
||||
<li>
|
||||
<RichText text={l.text} />
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p key={j}>
|
||||
<RichText text={l.text} />
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ n, total }: { n: number; total: number }) {
|
||||
return (
|
||||
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
|
||||
<span className="progress__bar">
|
||||
<span style={{ width: `${(n / total) * 100}%` }} />
|
||||
</span>
|
||||
<span className="progress__label">{ru.flow.question(n, total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
|
||||
return (
|
||||
<header className="head">
|
||||
{icon && (
|
||||
<span className="tile-icon tile-icon--blue">
|
||||
<Icon name={icon} size={26} />
|
||||
</span>
|
||||
)}
|
||||
{title && <h1 className="h1">{title}</h1>}
|
||||
<Body texts={lead} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
options,
|
||||
busy,
|
||||
onSubmit,
|
||||
}: {
|
||||
options: DialogButton[];
|
||||
busy: boolean;
|
||||
onSubmit: (b: DialogButton) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<DialogButton | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div className="options" role="radiogroup">
|
||||
{options.map((b) => {
|
||||
const on = picked?.data === b.data;
|
||||
return (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={on ? "option is-on" : "option"}
|
||||
onClick={() => setPicked(b)}
|
||||
>
|
||||
<span className="option__mark">{on && <Icon name="check" size={16} />}</span>
|
||||
<span className="option__label">{b.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={!picked || busy}
|
||||
onClick={() => picked && onSubmit(picked)}
|
||||
>
|
||||
{ru.common.next}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberScale({
|
||||
options,
|
||||
busy,
|
||||
onSubmit,
|
||||
}: {
|
||||
options: DialogButton[];
|
||||
busy: boolean;
|
||||
onSubmit: (b: DialogButton) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<DialogButton | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div className="numscale">
|
||||
<span className="numscale__value" aria-live="polite">
|
||||
{picked ? picked.label : "–"}
|
||||
</span>
|
||||
<div className="numscale__row" role="radiogroup">
|
||||
{options.map((b) => (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={picked?.data === b.data}
|
||||
className={picked?.data === b.data ? "num is-on" : "num"}
|
||||
onClick={() => setPicked(b)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={!picked || busy}
|
||||
onClick={() => picked && onSubmit(picked)}
|
||||
>
|
||||
{ru.common.next}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeText({
|
||||
skip,
|
||||
busy,
|
||||
onSend,
|
||||
onSkip,
|
||||
onExit,
|
||||
}: {
|
||||
skip?: DialogButton;
|
||||
busy: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onSkip: (b: DialogButton) => void;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
return (
|
||||
<>
|
||||
<div className="textarea">
|
||||
<label htmlFor="free" className="visually-hidden">
|
||||
{ru.messages.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="free"
|
||||
rows={5}
|
||||
maxLength={500}
|
||||
value={text}
|
||||
placeholder={ru.flow.textPlaceholder}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<span className="textarea__count">{text.length}/500</span>
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
{skip ? (
|
||||
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
|
||||
{skip.label}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn--ghost" onClick={onExit}>
|
||||
{ru.flow.exit}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={!text.trim() || busy}
|
||||
onClick={() => onSend(text.trim())}
|
||||
>
|
||||
{ru.common.next}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
|
||||
const style = (b: DialogButton) =>
|
||||
b.data.startsWith("med:taken")
|
||||
? { cls: "act act--teal", icon: "check" as IconName }
|
||||
: b.data.startsWith("med:side_effects")
|
||||
? { cls: "act act--red", icon: "alert" as IconName }
|
||||
: { cls: "act", icon: "clock" as IconName };
|
||||
return (
|
||||
<div className="acts">
|
||||
{options.map((b) => {
|
||||
const s = style(b);
|
||||
return (
|
||||
<button key={b.data} type="button" className={s.cls} disabled={busy} onClick={() => onPress(b)}>
|
||||
<span className="act__icon">
|
||||
<Icon name={s.icon} size={20} />
|
||||
</span>
|
||||
{b.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breathing() {
|
||||
return (
|
||||
<section className="breath" aria-label={ru.flow.breathe}>
|
||||
<Icon name="lungs" size={28} />
|
||||
<div className="breath__text">
|
||||
<p className="breath__title">{ru.flow.breathe}</p>
|
||||
<div className="breath__viz">
|
||||
<span>{ru.flow.breatheIn}</span>
|
||||
<span className="breath__ring" aria-hidden="true" />
|
||||
<span>{ru.flow.breatheOut}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
|
||||
return (
|
||||
<div className="stack narrow done">
|
||||
<span className="done__badge" aria-hidden="true">
|
||||
<Icon name="check" size={40} />
|
||||
</span>
|
||||
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
|
||||
<div className="panel">
|
||||
<Body texts={notes} />
|
||||
</div>
|
||||
<button type="button" className="btn btn--primary" onClick={onHome}>
|
||||
{ru.common.toToday}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Flow() {
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const { resources } = useHelp();
|
||||
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
|
||||
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
|
||||
const home = () => navigate("/today");
|
||||
|
||||
// First visit after sign-in: the engine opens the dialog (consent → age → onboarding), as /start in Telegram.
|
||||
useEffect(() => {
|
||||
if (!dialog.started && !dialog.busy) void dialog.start();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dialog.markRead();
|
||||
}, [dialog.pending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
|
||||
}, [step, navigate]);
|
||||
|
||||
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
|
||||
const say = (text: string) => void dialog.send({ type: "text", text }, text);
|
||||
|
||||
let content: ReactNode;
|
||||
if (!step) {
|
||||
content = dialog.error ? (
|
||||
<div className="stack narrow">
|
||||
<Notice tone="danger">{ru.common.networkError}</Notice>
|
||||
<button type="button" className="btn btn--primary" onClick={() => void dialog.start()}>
|
||||
{ru.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="loader" aria-label="Загрузка" />
|
||||
);
|
||||
} else if (step.kind === "idle") {
|
||||
content = <Done notes={step.notes} onHome={home} />;
|
||||
} else {
|
||||
const top = (
|
||||
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
|
||||
);
|
||||
let body: ReactNode;
|
||||
switch (step.kind) {
|
||||
case "consent":
|
||||
body = (
|
||||
<>
|
||||
<Head title={ru.flow.consentTitle} lead={[]} icon="shield" />
|
||||
<div className="panel panel--scroll">
|
||||
<Body texts={[...step.lead, step.title].filter(Boolean)} />
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
{step.buttons.map((b, i) => (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
className={i === 0 ? "btn btn--primary" : "btn btn--outline"}
|
||||
disabled={dialog.busy}
|
||||
onClick={() => press(b)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "number":
|
||||
body = (
|
||||
<>
|
||||
<Head title={step.title} lead={step.lead} />
|
||||
<NumberScale key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "actions":
|
||||
body = (
|
||||
<>
|
||||
<div className="reminder">
|
||||
<span className="tile-icon tile-icon--teal tile-icon--xl">
|
||||
<Icon name="pill" size={34} />
|
||||
</span>
|
||||
<h1 className="h1 center">{step.title || ru.meds.reminderTitle}</h1>
|
||||
<Body texts={step.lead} />
|
||||
</div>
|
||||
<MedActions options={step.options} busy={dialog.busy} onPress={press} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "text":
|
||||
body = (
|
||||
<>
|
||||
<Head title={step.title} lead={step.lead} />
|
||||
<FreeText
|
||||
key={stepKey}
|
||||
skip={step.skip}
|
||||
busy={dialog.busy}
|
||||
onSend={say}
|
||||
onSkip={press}
|
||||
onExit={home}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "crisis":
|
||||
body = (
|
||||
<>
|
||||
<header className="head">
|
||||
<span className="tile-icon tile-icon--teal tile-icon--xl">
|
||||
<Icon name="heart" size={32} />
|
||||
</span>
|
||||
<h1 className="h1">{ru.flow.supportTitle}</h1>
|
||||
</header>
|
||||
<div className="hotlines">
|
||||
{resources.map((r) => (
|
||||
<HotlineCard key={r.phone} r={r} />
|
||||
))}
|
||||
</div>
|
||||
<Breathing />
|
||||
<div className="panel">
|
||||
<Body texts={[step.text]} />
|
||||
</div>
|
||||
{step.buttons.map((b) => (
|
||||
<button key={b.data} type="button" className="btn btn--outline" disabled={dialog.busy} onClick={() => press(b)}>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="row-card"
|
||||
disabled={dialog.busy}
|
||||
onClick={() => press({ label: ru.flow.openPlan, data: "safety_plan" })}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{ru.flow.openPlan}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "plan":
|
||||
body = (
|
||||
<>
|
||||
<Head title={step.title || ru.flow.planTitle} lead={step.lead} />
|
||||
<ul className="plan">
|
||||
{step.lines.map((l) => (
|
||||
<li key={l} className="plan__item">
|
||||
<Icon name="shield" size={20} />
|
||||
<span>{l}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="btn btn--primary" onClick={home}>
|
||||
{ru.common.toToday}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "choice":
|
||||
body = (
|
||||
<>
|
||||
<Head title={step.title} lead={step.lead} />
|
||||
<Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
}
|
||||
content = (
|
||||
<div className="stack narrow" key={stepKey}>
|
||||
{top}
|
||||
{body}
|
||||
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell focus sos={step?.kind !== "crisis"}>
|
||||
{content}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { useToday } from "./useToday";
|
||||
|
||||
const DOT: Record<string, { cls: string; icon?: IconName }> = {
|
||||
taken: { cls: "wk wk--taken", icon: "check" },
|
||||
missed: { cls: "wk wk--missed", icon: "x" },
|
||||
pending: { cls: "wk wk--pending" },
|
||||
none: { cls: "wk" },
|
||||
};
|
||||
|
||||
// E4 · Моя схема приёма
|
||||
export function Meds() {
|
||||
const t = ru.meds;
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const { plan } = useToday();
|
||||
|
||||
const ask = () =>
|
||||
void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow"));
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="stack">
|
||||
<header className="head">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</header>
|
||||
|
||||
{plan && plan.prescriptions.length === 0 && <Notice>{t.empty}</Notice>}
|
||||
|
||||
{plan?.prescriptions.map((p) => (
|
||||
<article key={p.drug} className="rx">
|
||||
<div className="rx__head">
|
||||
<span className="tile-icon tile-icon--teal">
|
||||
<Icon name="pill" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="rx__drug">{p.drug.charAt(0).toUpperCase() + p.drug.slice(1)}</span>
|
||||
<span className="rx__by">
|
||||
{p.dose} · {t.byDoctor}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="rx__times">
|
||||
{p.times.map((time) => (
|
||||
<li key={time}>
|
||||
<Icon name="clock" size={18} />
|
||||
<strong>{time}</strong>
|
||||
<span>{t.tablet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ol className="week" aria-label="Отметки за неделю">
|
||||
{p.week.map((w, i) => (
|
||||
<li key={w.day} className="week__day">
|
||||
<span className="week__label">{t.week[i]}</span>
|
||||
<span className={DOT[w.status].cls} aria-label={w.status}>
|
||||
{DOT[w.status].icon && <Icon name={DOT[w.status].icon!} size={14} />}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{plan && plan.prescriptions.length > 0 && <Notice>{t.onlyDoctor}</Notice>}
|
||||
|
||||
{plan?.has_doctor && (
|
||||
<button type="button" className="btn btn--outline" onClick={ask} disabled={dialog.busy}>
|
||||
<Icon name="question" />
|
||||
{t.question}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
// E1 · Напоминание о приёме (opened from «Сегодня»); answers go to the engine like the Telegram reminder buttons.
|
||||
export function DoseReminder() {
|
||||
const t = ru.meds;
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const { plan } = useToday();
|
||||
const dose = plan?.doses.find((d) => String(d.id) === id);
|
||||
|
||||
const answer = (data: string, label: string) =>
|
||||
void dialog.send({ type: "button", button: `${data}:${id}` }, label).then((ok) => ok && navigate("/flow"));
|
||||
|
||||
return (
|
||||
<AppShell focus>
|
||||
<div className="stack narrow">
|
||||
<TopBar onBack={() => navigate("/today")} />
|
||||
<div className="reminder">
|
||||
<span className="tile-icon tile-icon--teal tile-icon--xl">
|
||||
<Icon name="pill" size={34} />
|
||||
</span>
|
||||
<h1 className="h1 center">{t.reminderTitle}</h1>
|
||||
<p className="muted center">{t.reminderLead}</p>
|
||||
{dose && (
|
||||
<div className="reminder__time">
|
||||
<Icon name="clock" />
|
||||
<span>{dose.time}</span>
|
||||
</div>
|
||||
)}
|
||||
{dose && (
|
||||
<p className="reminder__drug">
|
||||
{dose.drug.charAt(0).toUpperCase() + dose.drug.slice(1)}, {dose.dose}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="acts">
|
||||
<button type="button" className="act act--teal" disabled={dialog.busy} onClick={() => answer("med:taken", t.taken)}>
|
||||
<span className="act__icon">
|
||||
<Icon name="check" size={20} />
|
||||
</span>
|
||||
{t.taken}
|
||||
</button>
|
||||
<button type="button" className="act" disabled={dialog.busy} onClick={() => answer("med:skip", t.skip)}>
|
||||
<span className="act__icon">
|
||||
<Icon name="clock" size={20} />
|
||||
</span>
|
||||
{t.skip}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="act act--red"
|
||||
disabled={dialog.busy}
|
||||
onClick={() => answer("med:side_effects", t.sideEffects)}
|
||||
>
|
||||
<span className="act__icon">
|
||||
<Icon name="alert" size={20} />
|
||||
</span>
|
||||
{t.sideEffects}
|
||||
</button>
|
||||
</div>
|
||||
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppShell, TopBar } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
|
||||
// I1 · Сообщения: вся переписка с помощником; быстрые ответы — кнопки последнего сообщения движка.
|
||||
export function Messages() {
|
||||
const t = ru.messages;
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const [draft, setDraft] = useState("");
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const quick = dialog.pending.flatMap((r) => r.buttons.flat());
|
||||
|
||||
useEffect(() => {
|
||||
dialog.markRead();
|
||||
endRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [dialog.entries.length]);
|
||||
|
||||
const send = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || dialog.busy) return;
|
||||
setDraft("");
|
||||
if (!(await dialog.send({ type: "text", text }, text))) setDraft(text);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="stack chat">
|
||||
<TopBar onBack={() => navigate("/today")} title={t.title} />
|
||||
{dialog.entries.length === 0 && <p className="muted">{t.empty}</p>}
|
||||
<ol className="bubbles" aria-live="polite">
|
||||
{dialog.entries.map((e) =>
|
||||
e.from === "assistant" ? (
|
||||
<li key={e.id} className={`bubble bubble--bot bubble--${e.reply.kind}`}>
|
||||
<span className="bubble__avatar">
|
||||
<Icon name="smile" size={20} />
|
||||
</span>
|
||||
<p>{e.reply.text}</p>
|
||||
</li>
|
||||
) : (
|
||||
<li key={e.id} className="bubble bubble--me">
|
||||
<span className="visually-hidden">{t.you}: </span>
|
||||
<p>{e.text}</p>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
{quick.length > 0 && (
|
||||
<div className="quickreplies">
|
||||
{quick.map((b) => (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
className="qr"
|
||||
disabled={dialog.busy}
|
||||
onClick={() => void dialog.send({ type: "button", button: b.data }, b.label)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
<form className="composer" onSubmit={send}>
|
||||
<label htmlFor="msg" className="visually-hidden">
|
||||
{t.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
rows={1}
|
||||
value={draft}
|
||||
placeholder={t.inputPlaceholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
|
||||
<Icon name="send" size={20} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
// H1 / H2 · Профиль и мои данные
|
||||
export function Profile() {
|
||||
const t = ru.profile;
|
||||
const navigate = useNavigate();
|
||||
const { session, signOut } = useSession();
|
||||
const dialog = useDialog();
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (!d) return;
|
||||
if (confirm && !d.open) d.showModal();
|
||||
if (!confirm && d.open) d.close();
|
||||
}, [confirm]);
|
||||
|
||||
if (!session) return null;
|
||||
const anon = session.mode === "anonymous";
|
||||
|
||||
const revoke = async () => {
|
||||
setConfirm(false);
|
||||
if (await dialog.send({ type: "button", button: "consent:revoke" }, t.revoke)) navigate("/flow");
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="stack">
|
||||
<header className="head">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
</header>
|
||||
|
||||
<section className="stack stack--sm">
|
||||
<div className="row-card row-card--static">
|
||||
<span className="avatar avatar--lg">
|
||||
<Icon name={anon ? "lock" : "user"} />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__sub">{t.signInMode}</span>
|
||||
<span className="row-card__title">
|
||||
{anon ? t.anonymous : t.emias}
|
||||
{session.displayName ? ` · ${session.displayName}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{anon && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--soft"
|
||||
onClick={() => {
|
||||
signOut();
|
||||
navigate("/emias");
|
||||
}}
|
||||
>
|
||||
{t.connectEmias}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="stack stack--sm">
|
||||
<h2 className="h3">{t.data}</h2>
|
||||
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
|
||||
<span className="tile-icon">
|
||||
<Icon name="message" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{t.messages}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
<button type="button" className="row-card row-card--danger" onClick={() => setConfirm(true)}>
|
||||
<span className="tile-icon tile-icon--red">
|
||||
<Icon name="shield" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{t.revoke}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
{anon && (
|
||||
<button type="button" className="row-card" onClick={() => signOut({ wipe: true })}>
|
||||
<span className="tile-icon">
|
||||
<Icon name="trash" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{t.wipe}</span>
|
||||
<span className="row-card__sub">{t.wipeNote}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="row-card row-card--danger" onClick={() => signOut()}>
|
||||
<span className="tile-icon tile-icon--red">
|
||||
<Icon name="logout" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{t.logout}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog ref={ref} className="modal" aria-labelledby="revoke-title" onClose={() => setConfirm(false)}>
|
||||
<div className="modal__body">
|
||||
<div className="modal__head">
|
||||
<h2 id="revoke-title" className="h2">
|
||||
{t.revokeTitle}
|
||||
</h2>
|
||||
<button type="button" className="icon-btn" onClick={() => setConfirm(false)} aria-label={t.cancel}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">{t.revokeText}</p>
|
||||
<button type="button" className="btn btn--danger" onClick={revoke}>
|
||||
{t.revokeConfirm}
|
||||
</button>
|
||||
<button type="button" className="btn btn--soft" onClick={() => setConfirm(false)}>
|
||||
{t.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { TodayDose } from "../api/types";
|
||||
import { AppShell, Notice } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { classify } from "../dialog/step";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { humanDate, useToday } from "./useToday";
|
||||
|
||||
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
|
||||
return (
|
||||
<button type="button" className="row-card" onClick={onOpen}>
|
||||
<span className="tile-icon tile-icon--blue">
|
||||
<Icon name="calendar" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__sub">{ru.today.visit}</span>
|
||||
<span className="row-card__title">{humanDate(date)}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// C1 / C2 · Сегодня
|
||||
export function Today() {
|
||||
const t = ru.today;
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const dialog = useDialog();
|
||||
const { plan, failed } = useToday();
|
||||
|
||||
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
|
||||
const waiting = classify(dialog.pending);
|
||||
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
|
||||
|
||||
const go = (button: string, label: string) =>
|
||||
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
|
||||
|
||||
const pendingDoses = plan?.doses.filter((d) => d.status === "pending") ?? [];
|
||||
const takenDoses = plan?.doses.filter((d) => d.status === "taken") ?? [];
|
||||
const allDone = plan !== null && pendingDoses.length === 0 && plan.checkin_done && !hasOpenStep;
|
||||
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
|
||||
|
||||
const now = plan?.now ?? "00:00";
|
||||
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
|
||||
const visit = plan?.next_visit ? <VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} /> : null;
|
||||
|
||||
return (
|
||||
<AppShell aside={visit}>
|
||||
<div className="stack">
|
||||
<header className="today-head">
|
||||
<div>
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
{plan && <p className="muted">{humanDate(plan.today)}</p>}
|
||||
</div>
|
||||
<button type="button" className="icon-btn icon-btn--bell" onClick={() => navigate("/messages")} aria-label={ru.nav.messages}>
|
||||
<Icon name="bell" />
|
||||
{dialog.unread > 0 && <span className="dot" />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
|
||||
{hasOpenStep && (
|
||||
<button type="button" className="task task--hero" onClick={() => navigate("/flow")}>
|
||||
<span className="task__icon">
|
||||
<Icon name="message" />
|
||||
</span>
|
||||
<span className="task__text">
|
||||
<span className="task__title">{t.newMessage}</span>
|
||||
<span className="task__sub">{dialog.pending[0]?.text.split("\n")[0].slice(0, 80)}</span>
|
||||
</span>
|
||||
<span className="chip chip--teal-solid">{t.open}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{allDone ? (
|
||||
<section className="alldone">
|
||||
<span className="done__badge" aria-hidden="true">
|
||||
<Icon name="check" size={36} />
|
||||
</span>
|
||||
<h2 className="h2 center">{t.allDoneTitle}</h2>
|
||||
<p className="muted center">{nextDose ? t.allDoneNext(`завтра в ${nextDose}`) : t.allDoneNoMeds}</p>
|
||||
{plan && plan.prescriptions.length > 0 && (
|
||||
<button type="button" className="btn btn--primary" onClick={() => navigate("/meds")}>
|
||||
{t.viewMeds}
|
||||
<Icon name="chevronRight" size={20} />
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<div className="tasks">
|
||||
{pendingDoses.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={d.time <= now && !hasOpenStep ? "task task--hero" : "task"}
|
||||
onClick={() => openDose(d)}
|
||||
>
|
||||
<span className="task__icon task__icon--teal">
|
||||
<Icon name="pill" />
|
||||
</span>
|
||||
<span className="task__text">
|
||||
<span className="task__title">{t.takeMed}</span>
|
||||
<span className="task__sub">{d.time}</span>
|
||||
</span>
|
||||
{d.time <= now && <span className="chip chip--teal-solid">{t.now}</span>}
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
))}
|
||||
{plan && !plan.checkin_done && (
|
||||
<button type="button" className="task" onClick={() => go("menu:checkin", t.checkin)} disabled={dialog.busy}>
|
||||
<span className="task__icon task__icon--teal">
|
||||
<Icon name="smile" />
|
||||
</span>
|
||||
<span className="task__text">
|
||||
<span className="task__title">{t.checkin}</span>
|
||||
<span className="task__sub">{t.checkinHint}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allDone && (takenDoses.length > 0 || plan?.checkin_done) && (
|
||||
<section className="stack stack--sm">
|
||||
<h2 className="h3">{t.done}</h2>
|
||||
{takenDoses.map((d) => (
|
||||
<div key={d.id} className="done-row">
|
||||
<span className="badge-icon badge-icon--teal">
|
||||
<Icon name="check" size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<span className="done-row__title">{t.medTaken}</span>
|
||||
<span className="done-row__sub">{t.medTakenAt(d.time)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{plan?.checkin_done && (
|
||||
<div className="done-row">
|
||||
<span className="badge-icon badge-icon--teal">
|
||||
<Icon name="check" size={16} />
|
||||
</span>
|
||||
<span className="done-row__title">{t.checkinDone}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{plan?.next_visit && (
|
||||
<div className="only-phone">
|
||||
<VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session?.mode === "anonymous" || (plan && !plan.has_doctor) ? (
|
||||
<Notice>{t.noDoctor}</Notice>
|
||||
) : (
|
||||
<div className="quick">
|
||||
<button type="button" className="quick__btn" onClick={() => go("menu:med_question", t.medQuestion)} disabled={dialog.busy}>
|
||||
<Icon name="question" />
|
||||
{t.medQuestion}
|
||||
</button>
|
||||
<button type="button" className="quick__btn" onClick={() => go("menu:contact_doctor", t.messageDoctor)} disabled={dialog.busy}>
|
||||
<Icon name="message" />
|
||||
{t.messageDoctor}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
+36
-128
@@ -4,142 +4,50 @@ import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
|
||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
||||
const WEEK = [38, 52, 46, 60, 58, 72, 80]; // illustrative trend, %
|
||||
|
||||
function Facts({ items }: { items: readonly { ok: boolean; text: string }[] }) {
|
||||
return (
|
||||
<ul className="facts">
|
||||
{items.map((f) => (
|
||||
<li key={f.text} className={f.ok ? "fact" : "fact fact--no"}>
|
||||
<Icon name={f.ok ? "check" : "minus"} size={18} />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Illustration of a day with the service (decorative: the same facts are stated in text).
|
||||
function DayPreview() {
|
||||
const d = ru.welcome.day;
|
||||
return (
|
||||
<figure className="day" aria-hidden="true">
|
||||
<figcaption className="day__caption">{d.caption}</figcaption>
|
||||
<ol className="day__list">
|
||||
<li className="day__item day__item--meds">
|
||||
<span className="day__time">{d.meds.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="pill" size={16} />
|
||||
{d.meds.track}
|
||||
</span>
|
||||
<div className="day__row">
|
||||
<span className="day__text">{d.meds.text}</span>
|
||||
<span className="day__done">
|
||||
<Icon name="check" size={16} />
|
||||
{d.meds.done}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="day__item day__item--mood">
|
||||
<span className="day__time">{d.mood.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="pulse" size={16} />
|
||||
{d.mood.track}
|
||||
</span>
|
||||
<span className="day__text">{d.mood.text}</span>
|
||||
<div className="scale">
|
||||
{SCALE.map((n) => (
|
||||
<span key={n} className={`scale__dot scale__dot--${n}${n === 4 ? " is-picked" : ""}`}>
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="scale__ends">
|
||||
<span>{d.mood.low}</span>
|
||||
<span>{d.mood.high}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="day__item day__item--doctor">
|
||||
<span className="day__time">{d.doctor.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="doctor" size={16} />
|
||||
{d.doctor.track}
|
||||
</span>
|
||||
<div className="day__row">
|
||||
<span className="day__text">{d.doctor.text}</span>
|
||||
<span className="spark">
|
||||
{WEEK.map((v, i) => (
|
||||
<i key={i} style={{ height: `${v}%` }} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
// A1 · Выбор способа входа
|
||||
export function Welcome() {
|
||||
const navigate = useNavigate();
|
||||
const t = ru.welcome;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="welcome" id="main">
|
||||
<div className="welcome__inner">
|
||||
<section className="welcome__intro" aria-labelledby="welcome-title">
|
||||
<h1 id="welcome-title" className="display">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</section>
|
||||
<AppShell signedIn={false}>
|
||||
<div className="stack">
|
||||
<header className="head">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</header>
|
||||
|
||||
<section className="signin" aria-labelledby="choice-title">
|
||||
<h2 id="choice-title" className="signin__title">
|
||||
{t.choose}
|
||||
</h2>
|
||||
<div className="entry-grid">
|
||||
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon tile-icon--teal">
|
||||
<Icon name="docPlus" size={26} />
|
||||
</span>
|
||||
<span className="chip chip--teal">{ru.common.recommended}</span>
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{t.emias.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
<span className="entry__text">{t.emias.text}</span>
|
||||
</button>
|
||||
|
||||
<article className="option option--emias">
|
||||
<div className="option__head">
|
||||
<span className="option__icon">
|
||||
<Icon name="doctor" size={22} />
|
||||
</span>
|
||||
<h3 className="option__title">{t.emias.title}</h3>
|
||||
<span className="badge">{t.emias.badge}</span>
|
||||
</div>
|
||||
<Facts items={t.emias.facts} />
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={() => navigate("/emias")}>
|
||||
{t.emias.action}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article className="option option--anon">
|
||||
<div className="option__head">
|
||||
<span className="option__icon">
|
||||
<Icon name="lock" size={22} />
|
||||
</span>
|
||||
<h3 className="option__title">{t.anonymous.title}</h3>
|
||||
</div>
|
||||
<Facts items={t.anonymous.facts} />
|
||||
<button type="button" className="btn btn--secondary btn--block" onClick={() => navigate("/anonymous")}>
|
||||
{t.anonymous.action}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="welcome__aside">
|
||||
<DayPreview />
|
||||
<p className="disclaimer">{t.disclaimer}</p>
|
||||
</div>
|
||||
<button type="button" className="entry" onClick={() => navigate("/anonymous")}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon">
|
||||
<Icon name="lock" size={26} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{t.anonymous.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
<span className="entry__text">{t.anonymous.text}</span>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<p className="muted small">{t.disclaimer}</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { TodayPlan } from "../api/types";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
/** The patient's plan for today; refreshed whenever the dialog moves (a dose answered, a check-in done). */
|
||||
export function useToday(): { plan: TodayPlan | null; failed: boolean } {
|
||||
const { session } = useSession();
|
||||
const { pending } = useDialog();
|
||||
const [plan, setPlan] = useState<TodayPlan | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
let alive = true;
|
||||
api
|
||||
.today(session.token)
|
||||
.then((p) => {
|
||||
if (!alive) return;
|
||||
setPlan(p);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => alive && setFailed(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [session, pending]);
|
||||
|
||||
return { plan, failed };
|
||||
}
|
||||
|
||||
const MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"];
|
||||
const WEEKDAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||||
|
||||
/** "12 марта, вторник" — from an ISO date, no timezone shift. */
|
||||
export function humanDate(iso: string): string {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
return `${d} ${MONTHS[m - 1]}, ${WEEKDAYS[wd]}`;
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { sessionStore, type Session } from "./store";
|
||||
interface SessionApi {
|
||||
session: Session | null;
|
||||
signIn: (auth: AuthResult) => void;
|
||||
signOut: () => void;
|
||||
/** Leaves the account. Anonymous history is always wiped: it cannot be restored anyway. */
|
||||
signOut: (opts?: { wipe?: boolean }) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<SessionApi | null>(null);
|
||||
@@ -20,9 +21,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
const signOut = useCallback((opts?: { wipe?: boolean }) => {
|
||||
setSession((current) => {
|
||||
sessionStore.clear(current);
|
||||
sessionStore.clear(current, opts?.wipe ?? current?.mode === "anonymous");
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -12,9 +12,16 @@ export interface Session {
|
||||
}
|
||||
|
||||
export type ChatEntry =
|
||||
| { id: string; from: "assistant"; reply: Reply; answered: boolean }
|
||||
| { id: string; from: "assistant"; reply: Reply }
|
||||
| { id: string; from: "patient"; text: string };
|
||||
|
||||
/** Dialog snapshot per token: full history + the latest unanswered batch of engine replies. */
|
||||
export interface DialogSnapshot {
|
||||
entries: ChatEntry[];
|
||||
pending: Reply[];
|
||||
unread: number;
|
||||
}
|
||||
|
||||
interface KeyValue {
|
||||
get(key: string): string | null;
|
||||
set(key: string, value: string): void;
|
||||
@@ -46,7 +53,8 @@ const browserStorage: KeyValue = {
|
||||
};
|
||||
|
||||
const SESSION_KEY = "ptsr.session.v1";
|
||||
const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
|
||||
const dialogKey = (token: string) => `ptsr.dialog.v2.${token}`;
|
||||
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
|
||||
|
||||
function read<T>(storage: KeyValue, key: string): T | null {
|
||||
const raw = storage.get(key);
|
||||
@@ -62,12 +70,12 @@ export function createSessionStore(storage: KeyValue = browserStorage) {
|
||||
return {
|
||||
load: () => read<Session>(storage, SESSION_KEY),
|
||||
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
|
||||
clear: (s: Session | null) => {
|
||||
clear: (s: Session | null, wipeDialog = true) => {
|
||||
storage.remove(SESSION_KEY);
|
||||
if (s) storage.remove(chatKey(s.token));
|
||||
if (s && wipeDialog) storage.remove(dialogKey(s.token));
|
||||
},
|
||||
loadChat: (token: string) => read<ChatEntry[]>(storage, chatKey(token)) ?? [],
|
||||
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
|
||||
loadDialog: (token: string): DialogSnapshot => ({ ...EMPTY, ...read<DialogSnapshot>(storage, dialogKey(token)) }),
|
||||
saveDialog: (token: string, snap: DialogSnapshot) => storage.set(dialogKey(token), JSON.stringify(snap)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1522
-1033
File diff suppressed because it is too large
Load Diff
+46
-55
@@ -1,64 +1,57 @@
|
||||
/*
|
||||
* Design tokens — the single source of colours, type and spacing.
|
||||
* When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a
|
||||
* theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
|
||||
* Design tokens — single source of colours, type and spacing (docs/mock, series 2).
|
||||
* Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object.
|
||||
*/
|
||||
:root {
|
||||
/* Base: cool ink on cool white. */
|
||||
--c-ink: #13233a;
|
||||
--c-text: #13233a;
|
||||
--c-text-2: #46566b;
|
||||
--c-text-3: #6b7a8e;
|
||||
--c-page: #f4f7fb;
|
||||
/* Ink & surfaces */
|
||||
--c-navy: #0f2747; /* primary buttons, headings, desktop sidebar */
|
||||
--c-navy-hover: #0a1d37;
|
||||
--c-navy-soft: #e8eef6;
|
||||
--c-text: #13294b;
|
||||
--c-text-2: #4d5d73;
|
||||
--c-text-3: #7a889b;
|
||||
--c-page: #ffffff;
|
||||
--c-page-desk: #f4f7fb; /* desktop canvas behind white panels */
|
||||
--c-surface: #ffffff;
|
||||
--c-band: #e9f0fb; /* solid tint behind the welcome hero */
|
||||
--c-border: #dbe3ee;
|
||||
--c-border-strong: #b3c0d1;
|
||||
--c-tile: #f3f7fb; /* soft card fill */
|
||||
--c-border: #e2e9f1;
|
||||
--c-border-strong: #c9d4e1;
|
||||
|
||||
/* Care tracks: one colour per thing the service does. The same colour marks the track everywhere
|
||||
(welcome preview, chat questions), so colour carries meaning, never decoration. */
|
||||
--c-meds: #0e8c7f;
|
||||
--c-meds-soft: #e2f4f1;
|
||||
--c-mood: #e79a0c;
|
||||
--c-mood-soft: #fdf1da;
|
||||
--c-mood-ink: #8a5800; /* text on mood-soft (AA) */
|
||||
--c-doctor: #2350c8;
|
||||
--c-doctor-soft: #e7eefc;
|
||||
/* Selection & positive: teal */
|
||||
--c-teal: #17a08f;
|
||||
--c-teal-strong: #0e7e71;
|
||||
--c-teal-soft: #e3f5f2;
|
||||
|
||||
/* Action colour = the doctor/ЕМИАС blue. */
|
||||
--c-primary: #2350c8;
|
||||
--c-primary-hover: #1c43ac;
|
||||
--c-primary-active: #163689;
|
||||
--c-primary-soft: #e7eefc;
|
||||
/* Info: blue */
|
||||
--c-blue: #2f7fdb;
|
||||
--c-blue-soft: #eaf3fd;
|
||||
|
||||
/* Mood scale 1 → 5 (heavy → good); used only for the scale. */
|
||||
--c-scale-1: #d9485f;
|
||||
--c-scale-2: #ef8a3c;
|
||||
--c-scale-3: #f2c230;
|
||||
--c-scale-4: #7cc35b;
|
||||
--c-scale-5: #2fa36b;
|
||||
/* Help & crisis: red */
|
||||
--c-red: #e2383b;
|
||||
--c-red-strong: #c62b2e;
|
||||
--c-red-soft: #fdeded;
|
||||
--c-red-line: #f7d3d3;
|
||||
|
||||
--c-danger: #c8283a;
|
||||
--c-danger-hover: #a91f2f;
|
||||
--c-danger-soft: #fdebed;
|
||||
--c-success: #1e8a55;
|
||||
--c-warning-soft: #fff4dc;
|
||||
--c-warning-border: #e7b448;
|
||||
--c-focus: #3b82f6;
|
||||
/* Warning */
|
||||
--c-amber: #e69a12;
|
||||
--c-amber-soft: #fff4dd;
|
||||
|
||||
/* Type: Golos Text — Russian open-source UI typeface (OFL), bundled locally so the app works offline. */
|
||||
--c-focus: #2f7fdb;
|
||||
|
||||
/* Type: Golos Text (OFL), bundled locally — works offline. */
|
||||
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--fs-12: 0.75rem;
|
||||
--fs-13: 0.8125rem;
|
||||
--fs-14: 0.875rem;
|
||||
--fs-16: 1rem;
|
||||
--fs-18: 1.125rem;
|
||||
--fs-22: 1.375rem;
|
||||
--fs-20: 1.25rem;
|
||||
--fs-24: 1.5rem;
|
||||
--fs-28: 1.75rem;
|
||||
--fs-40: 2.5rem;
|
||||
--lh-tight: 1.15;
|
||||
--lh-text: 1.5;
|
||||
--fs-34: 2.125rem;
|
||||
--fs-48: 3rem;
|
||||
|
||||
/* Spacing: 4 px grid. */
|
||||
/* Spacing: 4 px grid */
|
||||
--s-1: 4px;
|
||||
--s-2: 8px;
|
||||
--s-3: 12px;
|
||||
@@ -68,19 +61,17 @@
|
||||
--s-8: 32px;
|
||||
--s-10: 40px;
|
||||
--s-12: 48px;
|
||||
--s-16: 64px;
|
||||
|
||||
/* Radius hierarchy: containers > controls > fields > chips. */
|
||||
--r-card: 14px;
|
||||
--r-control: 10px;
|
||||
--r-field: 8px;
|
||||
--r-chip: 6px;
|
||||
/* Radii: tiles > buttons > chips */
|
||||
--r-tile: 16px;
|
||||
--r-btn: 12px;
|
||||
--r-chip: 999px;
|
||||
|
||||
--tap: 48px; /* minimum touch target */
|
||||
--header-h: 64px;
|
||||
--content-max: 1160px;
|
||||
--tap: 52px;
|
||||
--nav-h: 64px;
|
||||
--sidebar-w: 232px;
|
||||
--rail-w: 300px;
|
||||
|
||||
/* Safe areas (notch, home indicator) for the mobile shell. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
|
||||
Reference in New Issue
Block a user