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 logging
|
||||||
import secrets
|
import secrets
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api import fixtures
|
from app.api import fixtures
|
||||||
|
from app.core import clock
|
||||||
from app.core.safety import CrisisResource, crisis_reply
|
from app.core.safety import CrisisResource, crisis_reply
|
||||||
from app.db import repo
|
from app.db import repo
|
||||||
|
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||||||
from app.db.identity import TOKEN_LEN
|
from app.db.identity import TOKEN_LEN
|
||||||
from app.db.session import get_clinical_session
|
from app.db.session import get_clinical_session
|
||||||
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
||||||
@@ -56,6 +61,43 @@ async def _attach_doctor(token: str, doctor_id: str) -> None:
|
|||||||
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_demo_persona(token: str) -> None:
|
||||||
|
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
|
||||||
|
|
||||||
|
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
|
||||||
|
doctor's card tell the same story.
|
||||||
|
"""
|
||||||
|
card = fixtures.card(token)
|
||||||
|
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
|
||||||
|
if card is None or item is None:
|
||||||
|
return
|
||||||
|
now = clock.now()
|
||||||
|
today = now.date()
|
||||||
|
async with get_clinical_session() as s:
|
||||||
|
if await repo.get_profile(s, token) is None:
|
||||||
|
await repo.upsert_profile(
|
||||||
|
s,
|
||||||
|
token=token,
|
||||||
|
doctor_id=fixtures.DOCTOR_ID,
|
||||||
|
modules=list(item.modules),
|
||||||
|
therapy_start=today,
|
||||||
|
next_visit=today + timedelta(days=15),
|
||||||
|
)
|
||||||
|
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
|
||||||
|
return
|
||||||
|
for p in card.prescriptions:
|
||||||
|
rx = Prescription(token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=today)
|
||||||
|
s.add(rx)
|
||||||
|
await s.flush()
|
||||||
|
for t in p.schedule.get("times", []):
|
||||||
|
hh, mm = (int(x) for x in t.split(":"))
|
||||||
|
s.add(
|
||||||
|
Dose(
|
||||||
|
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auth/emias", response_model=AuthOut)
|
@router.post("/auth/emias", response_model=AuthOut)
|
||||||
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||||||
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
||||||
@@ -74,7 +116,7 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
|||||||
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
||||||
if persona is None:
|
if persona is None:
|
||||||
raise HTTPException(404, "persona not found")
|
raise HTTPException(404, "persona not found")
|
||||||
await _attach_doctor(persona.token, fixtures.DOCTOR_ID)
|
await _seed_demo_persona(persona.token)
|
||||||
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
||||||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
||||||
|
|
||||||
@@ -87,6 +129,97 @@ async def auth_anonymous() -> AuthOut:
|
|||||||
return AuthOut(token=token, mode="anonymous")
|
return AuthOut(token=token, mode="anonymous")
|
||||||
|
|
||||||
|
|
||||||
|
class DoseItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
drug: str
|
||||||
|
dose: str
|
||||||
|
time: str # HH:MM, patient's local time
|
||||||
|
status: Literal["taken", "missed", "skipped", "pending"]
|
||||||
|
|
||||||
|
|
||||||
|
class WeekDay(BaseModel):
|
||||||
|
day: date
|
||||||
|
status: Literal["taken", "missed", "pending", "none"]
|
||||||
|
|
||||||
|
|
||||||
|
class PrescriptionItem(BaseModel):
|
||||||
|
drug: str
|
||||||
|
dose: str
|
||||||
|
times: list[str]
|
||||||
|
week: list[WeekDay] # Mon..Sun of the current week
|
||||||
|
|
||||||
|
|
||||||
|
class TodayOut(BaseModel):
|
||||||
|
today: date
|
||||||
|
now: str # HH:MM on the (demo) clock — the client never uses its own clock
|
||||||
|
has_doctor: bool
|
||||||
|
checkin_done: bool
|
||||||
|
next_visit: date | None
|
||||||
|
doses: list[DoseItem]
|
||||||
|
prescriptions: list[PrescriptionItem]
|
||||||
|
|
||||||
|
|
||||||
|
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
|
||||||
|
if not statuses:
|
||||||
|
return "none"
|
||||||
|
if any(s in ("missed", "skipped") for s in statuses):
|
||||||
|
return "missed"
|
||||||
|
return "taken" if all(s == "taken" for s in statuses) else "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/{token}/today", response_model=TodayOut)
|
||||||
|
async def my_today(token: str) -> TodayOut:
|
||||||
|
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
|
||||||
|
|
||||||
|
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
|
||||||
|
"""
|
||||||
|
now = clock.now()
|
||||||
|
today = now.date()
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
async with get_clinical_session() as s:
|
||||||
|
profile = await repo.get_profile(s, token)
|
||||||
|
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
|
||||||
|
doses = (
|
||||||
|
await s.scalars(
|
||||||
|
select(Dose)
|
||||||
|
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
|
||||||
|
.order_by(Dose.planned_at)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
|
||||||
|
|
||||||
|
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
|
||||||
|
today_doses: list[DoseItem] = []
|
||||||
|
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
|
||||||
|
for d in doses:
|
||||||
|
local = d.planned_at.astimezone(now.tzinfo)
|
||||||
|
marks[(d.prescription_id, local.date())].append(d.status)
|
||||||
|
rx = by_rx.get(d.prescription_id)
|
||||||
|
if local.date() == today and rx is not None:
|
||||||
|
today_doses.append(
|
||||||
|
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
|
||||||
|
)
|
||||||
|
week = [week_start + timedelta(days=i) for i in range(7)]
|
||||||
|
return TodayOut(
|
||||||
|
today=today,
|
||||||
|
now=now.strftime("%H:%M"),
|
||||||
|
has_doctor=profile is not None,
|
||||||
|
checkin_done=checkin is not None and checkin.mood is not None,
|
||||||
|
next_visit=profile.next_visit if profile else None,
|
||||||
|
doses=today_doses,
|
||||||
|
prescriptions=[
|
||||||
|
PrescriptionItem(
|
||||||
|
drug=p.drug,
|
||||||
|
dose=p.dose,
|
||||||
|
times=list((p.schedule or {}).get("times", [])),
|
||||||
|
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
|
||||||
|
)
|
||||||
|
for p in prescriptions
|
||||||
|
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||||||
async def crisis_resources() -> list[CrisisResource]:
|
async def crisis_resources() -> list[CrisisResource]:
|
||||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
|
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -9,6 +10,7 @@ from app.api import fixtures
|
|||||||
from app.core import clock
|
from app.core import clock
|
||||||
from app.db import identity_repo, repo, session
|
from app.db import identity_repo, repo, session
|
||||||
from app.db.bootstrap import init_db
|
from app.db.bootstrap import init_db
|
||||||
|
from app.db.clinical import Dose, Prescription
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
MEM = "sqlite+aiosqlite:///:memory:"
|
MEM = "sqlite+aiosqlite:///:memory:"
|
||||||
@@ -81,6 +83,37 @@ async def test_emias_demo_persona(client: AsyncClient) -> None:
|
|||||||
assert missing.status_code == 404
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
|
||||||
|
now = clock.now()
|
||||||
|
today = now.date()
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5))
|
||||||
|
rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]})
|
||||||
|
s.add(rx)
|
||||||
|
await s.flush()
|
||||||
|
morning = datetime.combine(today, time(9, 0), now.tzinfo)
|
||||||
|
s.add_all(
|
||||||
|
[
|
||||||
|
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"),
|
||||||
|
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6)
|
||||||
|
|
||||||
|
body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()
|
||||||
|
assert body["has_doctor"] and body["checkin_done"]
|
||||||
|
assert body["next_visit"] == (today + timedelta(days=5)).isoformat()
|
||||||
|
assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")]
|
||||||
|
week = body["prescriptions"][0]["week"]
|
||||||
|
assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
|
||||||
|
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
|
||||||
|
body = (await client.get(f"/api/v1/me/{token}/today")).json()
|
||||||
|
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == []
|
||||||
|
|
||||||
|
|
||||||
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
|
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
|
||||||
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
|
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
|
||||||
assert "112" in phones
|
assert "112" in phones
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.vite/
|
||||||
|
|||||||
+12
-1
@@ -30,9 +30,20 @@ src/theme/tokens.css цвета, шрифт, отступы — единств
|
|||||||
src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
|
src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
|
||||||
src/api/ типы-зеркала контрактов бэкенда и клиент
|
src/api/ типы-зеркала контрактов бэкенда и клиент
|
||||||
src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain
|
src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain
|
||||||
src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat
|
src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts)
|
||||||
|
src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4),
|
||||||
|
«Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Как устроены экраны
|
||||||
|
|
||||||
|
Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его
|
||||||
|
как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст,
|
||||||
|
напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда.
|
||||||
|
|
||||||
|
Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю,
|
||||||
|
следующий визит, время по демо-часам).
|
||||||
|
|
||||||
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
|
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
|
||||||
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
|
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types";
|
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types";
|
||||||
|
|
||||||
// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
|
// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
|
||||||
const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
|
const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
|
||||||
@@ -35,6 +35,7 @@ export const api = {
|
|||||||
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
|
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
|
||||||
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
|
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
|
||||||
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
|
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
|
||||||
|
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
|
||||||
sendEvent: async (token: string, event: DialogEvent) =>
|
sendEvent: async (token: string, event: DialogEvent) =>
|
||||||
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
|
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
|
||||||
outbox: async (token: string) =>
|
outbox: async (token: string) =>
|
||||||
|
|||||||
@@ -39,3 +39,30 @@ export interface DemoPersona {
|
|||||||
therapy_day: number;
|
therapy_day: number;
|
||||||
scenario: string | null;
|
scenario: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DoseStatus = "taken" | "missed" | "skipped" | "pending";
|
||||||
|
|
||||||
|
export interface TodayDose {
|
||||||
|
id: number;
|
||||||
|
drug: string;
|
||||||
|
dose: string;
|
||||||
|
time: string;
|
||||||
|
status: DoseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayPrescription {
|
||||||
|
drug: string;
|
||||||
|
dose: string;
|
||||||
|
times: string[];
|
||||||
|
week: { day: string; status: "taken" | "missed" | "pending" | "none" }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayPlan {
|
||||||
|
today: string;
|
||||||
|
now: string;
|
||||||
|
has_doctor: boolean;
|
||||||
|
checkin_done: boolean;
|
||||||
|
next_visit: string | null;
|
||||||
|
doses: TodayDose[];
|
||||||
|
prescriptions: TodayPrescription[];
|
||||||
|
}
|
||||||
|
|||||||
+159
-47
@@ -1,64 +1,176 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
|
||||||
import { ru } from "../content/ru";
|
import { ru } from "../content/ru";
|
||||||
import { HelpSheet } from "./HelpSheet";
|
import { useDialog } from "../dialog/DialogContext";
|
||||||
import { Icon, LogoMark } from "./Icon";
|
import { HotlineCard, useHelp } from "./HelpSheet";
|
||||||
|
import { Icon, LogoMark, type IconName } from "./Icon";
|
||||||
|
|
||||||
export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
|
/*
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
* Layout from docs/mock (series 2).
|
||||||
|
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
|
||||||
|
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TABS: { to: string; label: string; icon: IconName }[] = [
|
||||||
|
{ to: "/today", label: ru.nav.today, icon: "home" },
|
||||||
|
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
|
||||||
|
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
|
||||||
|
{ to: "/profile", label: ru.nav.profile, icon: "user" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */
|
||||||
|
export function EmergencyBlock() {
|
||||||
|
const { resources, open } = useHelp();
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<section className="sosblock" aria-label={ru.help.block}>
|
||||||
<header className="topbar">
|
<button type="button" className="sosblock__head" onClick={open}>
|
||||||
<div className="topbar__inner">
|
<span className="sosblock__icon">
|
||||||
<Link to="/" className="brand" aria-label={ru.product.name}>
|
<Icon name="phone" size={18} />
|
||||||
<LogoMark />
|
</span>
|
||||||
<span className="brand__text">
|
<span>{ru.help.block}</span>
|
||||||
<span className="brand__name">{ru.product.name}</span>
|
<Icon name="chevronRight" size={20} />
|
||||||
<span className="brand__tagline">{ru.product.tagline}</span>
|
</button>
|
||||||
</span>
|
<div className="sosblock__lines">
|
||||||
</Link>
|
{resources.slice(0, 2).map((r) => (
|
||||||
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}>
|
<HotlineCard key={r.phone} r={r} size="sm" />
|
||||||
<Icon name="phone" size={18} />
|
))}
|
||||||
<span>{ru.help.button}</span>
|
</div>
|
||||||
</button>
|
</section>
|
||||||
</div>
|
);
|
||||||
</header>
|
}
|
||||||
|
|
||||||
{children}
|
function Sidebar({ signedIn }: { signedIn: boolean }) {
|
||||||
|
const { open } = useHelp();
|
||||||
{footer && (
|
const { unread } = useDialog();
|
||||||
<footer className="footer">
|
return (
|
||||||
<div className="footer__inner">
|
<nav className="sidebar" aria-label="Разделы">
|
||||||
<p className="footer__line">
|
<Link to={signedIn ? "/today" : "/"} className="sidebar__brand">
|
||||||
<Icon name="lock" size={16} />
|
<LogoMark />
|
||||||
<span>{ru.footer.law}</span>
|
<span>
|
||||||
</p>
|
<span className="sidebar__name">{ru.product.name}</span>
|
||||||
<p className="footer__line footer__line--muted">{ru.footer.prototype}</p>
|
<span className="sidebar__tag">{ru.product.tagline}</span>
|
||||||
</div>
|
</span>
|
||||||
</footer>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackLink({ to, children }: { to: string; children: ReactNode }) {
|
export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
|
||||||
return (
|
const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
|
||||||
<Link to={to} className="back-link">
|
|
||||||
<Icon name="arrowLeft" size={18} />
|
|
||||||
<span>{children}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div className={`notice notice--${tone}`} role="note">
|
<div className={`notice notice--${tone}`} role="note">
|
||||||
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
|
<Icon name={icon} />
|
||||||
<p>{children}</p>
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
{onBack && (
|
||||||
|
<button type="button" className="icon-btn" onClick={onBack} aria-label={ru.common.back}>
|
||||||
|
<Icon name="arrowLeft" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{title && <span className="topbar__title">{title}</span>}
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { CrisisResource } from "../api/types";
|
import type { CrisisResource } from "../api/types";
|
||||||
import { ru } from "../content/ru";
|
import { ru } from "../content/ru";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
// 112 is shown even when the API is unreachable: the help sheet must never be empty.
|
// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»).
|
||||||
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }];
|
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }];
|
||||||
|
|
||||||
const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
||||||
|
|
||||||
export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
interface HelpApi {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
resources: CrisisResource[];
|
||||||
|
open: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<HelpApi>({ resources: FALLBACK, open: () => undefined });
|
||||||
|
export const useHelp = () => useContext(Ctx);
|
||||||
|
|
||||||
|
/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */
|
||||||
|
export function HelpProvider({ children }: { children: ReactNode }) {
|
||||||
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
|
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
|
||||||
|
const [isOpen, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const open = useCallback(() => setOpen(true), []);
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ resources, open }}>
|
||||||
|
{children}
|
||||||
|
<HelpSheet open={isOpen} onClose={() => setOpen(false)} resources={resources} />
|
||||||
|
</Ctx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) {
|
||||||
|
const emergency = r.phone.replace(/\D/g, "").length <= 3;
|
||||||
|
return (
|
||||||
|
<a className={`hotline hotline--${size}`} href={telHref(r.phone)}>
|
||||||
|
<span className="hotline__icon">
|
||||||
|
<Icon name={emergency ? "phone" : "headset"} size={size === "lg" ? 26 : 20} />
|
||||||
|
</span>
|
||||||
|
<span className="hotline__text">
|
||||||
|
{emergency ? (
|
||||||
|
<>
|
||||||
|
<span className="hotline__phone">{r.phone}</span>
|
||||||
|
<span className="hotline__name">{r.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="hotline__name hotline__name--strong">{r.name}</span>
|
||||||
|
<span className="hotline__phone hotline__phone--sm">{r.phone}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{r.note && size === "lg" && <span className="hotline__note">{r.note}</span>}
|
||||||
|
</span>
|
||||||
|
{size === "lg" && <Icon name="chevronRight" size={20} />}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = ref.current;
|
const dialog = ref.current;
|
||||||
if (!dialog) return;
|
if (!dialog) return;
|
||||||
@@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
|
|||||||
onClick={(e) => e.target === ref.current && onClose()}
|
onClick={(e) => e.target === ref.current && onClose()}
|
||||||
>
|
>
|
||||||
<div className="sheet__body">
|
<div className="sheet__body">
|
||||||
<div className="sheet__head">
|
<div className="sheet__bar">
|
||||||
<h2 id="help-title" className="sheet__title">
|
|
||||||
{ru.help.title}
|
|
||||||
</h2>
|
|
||||||
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
|
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
|
<h2 id="help-title" className="sheet__title">
|
||||||
|
{ru.help.title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="sheet__lead">{ru.help.lead}</p>
|
<div className="sos">
|
||||||
<ul className="hotlines">
|
<span className="sos__ring">
|
||||||
|
<span className="sos__btn">
|
||||||
|
<Icon name="phone" size={40} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<p className="sos__lead">{ru.help.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div className="hotlines">
|
||||||
{resources.map((r) => (
|
{resources.map((r) => (
|
||||||
<li key={r.phone}>
|
<HotlineCard key={r.phone} r={r} />
|
||||||
<a className="hotline" href={telHref(r.phone)}>
|
|
||||||
<span className="hotline__icon">
|
|
||||||
<Icon name="phone" size={22} />
|
|
||||||
</span>
|
|
||||||
<span className="hotline__text">
|
|
||||||
<span className="hotline__phone">{r.phone}</span>
|
|
||||||
<span className="hotline__name">{r.name}</span>
|
|
||||||
{r.note && <span className="hotline__note">{r.note}</span>}
|
|
||||||
</span>
|
|
||||||
<span className="hotline__call">{ru.help.call}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
|
<p className="notice notice--info">
|
||||||
|
<Icon name="info" />
|
||||||
|
<span>{ru.help.alwaysNote}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
+104
-26
@@ -1,14 +1,37 @@
|
|||||||
// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers.
|
// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers.
|
||||||
|
|
||||||
const paths = {
|
const paths = {
|
||||||
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
home: <path d="M4 10.5L12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-6h-5v6H5a1 1 0 0 1-1-1v-8.5z" />,
|
||||||
minus: <path d="M6 12h12" />,
|
pill: (
|
||||||
phone: (
|
<>
|
||||||
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
<rect x="3" y="8.5" width="18" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
||||||
|
<path d="M9.5 9.5l5 5" />
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
|
doctor: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="7.5" r="3.5" />
|
||||||
|
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
||||||
|
<path d="M12 15.5v3m-1.5-1.5h3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
user: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M4.5 20.5c.8-3.7 3.8-6 7.5-6s6.7 2.3 7.5 6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
bell: (
|
||||||
|
<>
|
||||||
|
<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 2h-15L6 16.5z" />
|
||||||
|
<path d="M10 20.5a2 2 0 0 0 4 0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
chevronRight: <path d="M9.5 6l6 6-6 6" />,
|
||||||
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
|
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
|
||||||
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />,
|
close: <path d="M6 6l12 12M18 6L6 18" />,
|
||||||
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
|
||||||
|
x: <path d="M7 7l10 10M17 7L7 17" />,
|
||||||
info: (
|
info: (
|
||||||
<>
|
<>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -21,33 +44,88 @@ const paths = {
|
|||||||
<path d="M12 10v4.5m0 2.5v.5" />
|
<path d="M12 10v4.5m0 2.5v.5" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
phone: (
|
||||||
|
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
|
||||||
|
),
|
||||||
|
headset: (
|
||||||
|
<>
|
||||||
|
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
|
||||||
|
<rect x="3" y="13.5" width="4" height="6" rx="1.5" />
|
||||||
|
<rect x="17" y="13.5" width="4" height="6" rx="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
calendar: (
|
||||||
|
<>
|
||||||
|
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
|
||||||
|
<path d="M3.5 9.5h17M8 3v4m8-4v4M8 13h1m3 0h1m3 0h1M8 16.5h1m3 0h1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
clock: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M12 7.5V12l3 2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
pin: (
|
||||||
|
<>
|
||||||
|
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
|
||||||
|
<circle cx="12" cy="10" r="2.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
smile: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M8.5 14c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2M9 9.8v.4m6-.4v.4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
doc: (
|
||||||
|
<>
|
||||||
|
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
|
||||||
|
<path d="M14 3.5V8h4M9 12h6m-6 3.5h6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
docPlus: (
|
||||||
|
<>
|
||||||
|
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
|
||||||
|
<path d="M14 3.5V8h4M12 11v6m-3-3h6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
shield: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6L12 3z" />
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
lock: (
|
lock: (
|
||||||
<>
|
<>
|
||||||
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
|
<rect x="5" y="10.5" width="14" height="10" rx="2" />
|
||||||
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
|
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
pill: (
|
edit: <path d="M5 19l1-4L15.5 5.5a2 2 0 0 1 3 3L9 18l-4 1zM14 7l3 3" />,
|
||||||
<>
|
trash: <path d="M5 7h14M10 7V4.5h4V7m-7 0l1 12.5h8L17 7M10.5 11v5m3-5v5" />,
|
||||||
<rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-45 12 12)" />
|
|
||||||
<path d="M9.5 9.5l5 5" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
pulse: <path d="M3 12h4l2-5 4 10 2-5h6" />,
|
|
||||||
doctor: (
|
|
||||||
<>
|
|
||||||
<circle cx="12" cy="7.5" r="3.5" />
|
|
||||||
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
|
|
||||||
<path d="M12 15.5v3m-1.5-1.5h3" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
close: <path d="M6 6l12 12M18 6L6 18" />,
|
|
||||||
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
|
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
|
||||||
|
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
|
||||||
|
heart: <path d="M12 19.5s-7.5-4.4-7.5-10a4.2 4.2 0 0 1 7.5-2.6 4.2 4.2 0 0 1 7.5 2.6c0 5.6-7.5 10-7.5 10z" />,
|
||||||
|
lungs: (
|
||||||
|
<>
|
||||||
|
<path d="M12 4v8m0 0c-1 1.2-2.3 2-3.5 2M12 12c1 1.2 2.3 2 3.5 2" />
|
||||||
|
<path d="M8.5 7.5C6 8 4 11.5 4 16c0 2 1 3.5 3 3.5s3-1.5 3-4V9M15.5 7.5C18 8 20 11.5 20 16c0 2-1 3.5-3 3.5s-3-1.5-3-4V9" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
question: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M9.6 9.5a2.4 2.4 0 0 1 4.7.7c0 1.6-2.3 2-2.3 3.6m0 2.7v.3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
message: <path d="M4 5.5h16v11H9.5L5 20v-3.5H4z" />,
|
||||||
|
plus: <path d="M12 5v14M5 12h14" />,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof paths;
|
export type IconName = keyof typeof paths;
|
||||||
|
|
||||||
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
|
export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -55,7 +133,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={1.75}
|
strokeWidth={1.8}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
role={label ? "img" : undefined}
|
role={label ? "img" : undefined}
|
||||||
@@ -71,7 +149,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
|
|||||||
export function LogoMark({ size = 32 }: { size?: number }) {
|
export function LogoMark({ size = 32 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
||||||
<rect width="32" height="32" rx="6" fill="var(--c-primary)" />
|
<rect width="32" height="32" rx="9" fill="var(--c-teal)" />
|
||||||
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
|
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
+128
-63
@@ -1,100 +1,165 @@
|
|||||||
/*
|
/*
|
||||||
* All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/
|
* All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales,
|
||||||
* for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here.
|
* reminders — comes from the engine (config/texts/) and is NOT duplicated here.
|
||||||
*/
|
*/
|
||||||
export const ru = {
|
export const ru = {
|
||||||
product: {
|
product: {
|
||||||
name: "Сопровождение пациента",
|
name: "Сопровождение пациента",
|
||||||
tagline: "между визитами к врачу",
|
tagline: "между визитами к врачу",
|
||||||
prototype: "Прототип · синтетические данные",
|
},
|
||||||
|
nav: {
|
||||||
|
today: "Сегодня",
|
||||||
|
meds: "Лекарства",
|
||||||
|
doctor: "Врач",
|
||||||
|
profile: "Профиль",
|
||||||
|
messages: "Сообщения",
|
||||||
|
signIn: "Вход",
|
||||||
|
help: "Помощь",
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
button: "Нужна помощь",
|
block: "Экстренная помощь",
|
||||||
title: "Если вам сейчас очень тяжело",
|
title: "Экстренная помощь",
|
||||||
lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:",
|
lead: "Если вам нужна срочная помощь, позвоните сразу.",
|
||||||
call: "Позвонить",
|
call: "Позвонить",
|
||||||
close: "Закрыть",
|
close: "Закрыть",
|
||||||
fallbackName: "Экстренные службы",
|
alwaysNote: "Вы не одни. Помощь доступна круглосуточно.",
|
||||||
fallbackNote: "если есть угроза жизни прямо сейчас",
|
fallbackName: "Единый номер экстренных служб",
|
||||||
|
fallbackNote: "",
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
back: "Назад",
|
||||||
|
next: "Далее",
|
||||||
|
skip: "Пропустить",
|
||||||
|
send: "Отправить",
|
||||||
|
retry: "Повторить",
|
||||||
|
toToday: "Вернуться на сегодня",
|
||||||
|
recommended: "Рекомендуем",
|
||||||
|
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
|
||||||
},
|
},
|
||||||
welcome: {
|
welcome: {
|
||||||
title: "Лечение продолжается и между приёмами",
|
title: "Как вы хотите войти в приложение?",
|
||||||
lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.",
|
lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
|
||||||
day: {
|
|
||||||
caption: "Обычный день с сервисом",
|
|
||||||
meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" },
|
|
||||||
mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" },
|
|
||||||
doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" },
|
|
||||||
},
|
|
||||||
choose: "Как вы хотите войти?",
|
|
||||||
emias: {
|
emias: {
|
||||||
title: "Через ЕМИАС",
|
title: "Через ЕМИАС",
|
||||||
badge: "Рекомендуем",
|
text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
|
||||||
facts: [
|
|
||||||
{ ok: true, text: "Назначения врача подгружаются автоматически" },
|
|
||||||
{ ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" },
|
|
||||||
{ ok: true, text: "История доступна с любого устройства" },
|
|
||||||
],
|
|
||||||
action: "Войти через ЕМИАС",
|
|
||||||
},
|
},
|
||||||
anonymous: {
|
anonymous: {
|
||||||
title: "Анонимно",
|
title: "Анонимно",
|
||||||
facts: [
|
text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
|
||||||
{ ok: true, text: "Не нужны имя, полис и телефон" },
|
|
||||||
{ ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" },
|
|
||||||
{ ok: false, text: "Врач не видит ваши ответы и не получает сигналов" },
|
|
||||||
{ ok: false, text: "История доступна только на этом устройстве" },
|
|
||||||
],
|
|
||||||
action: "Продолжить анонимно",
|
|
||||||
},
|
},
|
||||||
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
|
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
|
||||||
},
|
},
|
||||||
emias: {
|
emias: {
|
||||||
back: "Способы входа",
|
title: "Введите код от врача",
|
||||||
title: "Вход через ЕМИАС",
|
lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.",
|
||||||
demoNotice:
|
demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.",
|
||||||
"Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.",
|
codeLabel: "Код подключения",
|
||||||
codeLabel: "Код подключения от врача",
|
|
||||||
codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.",
|
|
||||||
codePlaceholder: "Код из памятки",
|
codePlaceholder: "Код из памятки",
|
||||||
submit: "Войти",
|
submit: "Продолжить",
|
||||||
submitting: "Проверяем код…",
|
submitting: "Проверяем код…",
|
||||||
codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.",
|
codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.",
|
||||||
networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.",
|
|
||||||
or: "или",
|
or: "или",
|
||||||
demoTitle: "Демо-пациенты",
|
demoTitle: "Демо-пациенты",
|
||||||
demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.",
|
demoLead: "Нет кода? Войдите под синтетической учётной записью.",
|
||||||
demoDay: (day: number) => `${day}-й день терапии`,
|
demoDay: (day: number) => `${day}-й день терапии`,
|
||||||
demoAction: "Войти",
|
|
||||||
demoEmpty: "Демо-пациенты недоступны.",
|
|
||||||
},
|
},
|
||||||
anonymous: {
|
anonymous: {
|
||||||
back: "Способы входа",
|
|
||||||
title: "Анонимный вход",
|
title: "Анонимный вход",
|
||||||
lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.",
|
lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
|
||||||
points: [
|
yesTitle: "Что будет доступно:",
|
||||||
"Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.",
|
yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
|
||||||
"Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.",
|
noTitle: "Чего не будет:",
|
||||||
"Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.",
|
no: ["Врач не увидит ответы", "История только на этом устройстве"],
|
||||||
],
|
warning: "После выхода историю восстановить нельзя.",
|
||||||
warning:
|
submit: "Продолжить анонимно",
|
||||||
"История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.",
|
submitting: "Создаём профиль…",
|
||||||
submit: "Начать анонимно",
|
|
||||||
submitting: "Создаём анонимный профиль…",
|
|
||||||
},
|
},
|
||||||
chat: {
|
today: {
|
||||||
modeEmias: "ЕМИАС",
|
title: "Сегодня",
|
||||||
modeAnonymous: "Анонимно",
|
takeMed: "Принять лекарство",
|
||||||
|
now: "Сейчас",
|
||||||
|
checkin: "Отметка самочувствия",
|
||||||
|
checkinHint: "меньше минуты",
|
||||||
|
done: "Уже выполнено",
|
||||||
|
medTaken: "Лекарство",
|
||||||
|
medTakenAt: (time: string) => `принято, ${time}`,
|
||||||
|
checkinDone: "Самочувствие отмечено",
|
||||||
|
allDoneTitle: "На сегодня всё сделано",
|
||||||
|
allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`,
|
||||||
|
allDoneNoMeds: "Следующая отметка самочувствия — завтра",
|
||||||
|
viewMeds: "Посмотреть лекарства",
|
||||||
|
visit: "Следующий визит к врачу",
|
||||||
|
medQuestion: "Вопрос о лекарстве",
|
||||||
|
messageDoctor: "Сообщение врачу",
|
||||||
|
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.",
|
||||||
|
newMessage: "Новое сообщение",
|
||||||
|
open: "Открыть",
|
||||||
|
},
|
||||||
|
meds: {
|
||||||
|
title: "Моя схема приёма",
|
||||||
|
lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.",
|
||||||
|
byDoctor: "По назначению врача",
|
||||||
|
tablet: "1 приём",
|
||||||
|
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
|
||||||
|
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
|
||||||
|
empty: "Назначения появятся здесь после входа через ЕМИАС.",
|
||||||
|
question: "Вопрос о лекарстве",
|
||||||
|
reminderTitle: "Время принять препарат",
|
||||||
|
reminderLead: "По назначению врача",
|
||||||
|
taken: "Принял(а)",
|
||||||
|
skip: "Пропущу",
|
||||||
|
sideEffects: "Есть побочные эффекты",
|
||||||
|
},
|
||||||
|
doctor: {
|
||||||
|
title: "Врач",
|
||||||
|
visit: "Следующий визит",
|
||||||
|
visitNone: "Дата следующего визита пока не назначена",
|
||||||
|
attending: "Ваш лечащий врач",
|
||||||
|
write: "Написать врачу",
|
||||||
|
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.",
|
||||||
|
earlier: "Записаться к врачу раньше",
|
||||||
|
plan: "Мой план безопасности",
|
||||||
|
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "Профиль",
|
||||||
|
signInMode: "Способ входа",
|
||||||
|
emias: "ЕМИАС",
|
||||||
|
anonymous: "Анонимно",
|
||||||
|
connectEmias: "Подключить ЕМИАС",
|
||||||
|
data: "Мои данные",
|
||||||
|
revoke: "Отозвать согласие",
|
||||||
|
revokeTitle: "Отозвать согласие?",
|
||||||
|
revokeText: "Ваши ответы больше не будут обрабатываться в приложении.",
|
||||||
|
revokeConfirm: "Да, отозвать",
|
||||||
|
cancel: "Отмена",
|
||||||
|
wipe: "Удалить данные с устройства",
|
||||||
|
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
|
||||||
logout: "Выйти",
|
logout: "Выйти",
|
||||||
logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.",
|
messages: "Сообщения",
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
|
||||||
|
period: "За последний месяц",
|
||||||
|
textPlaceholder: "Напишите несколько слов",
|
||||||
|
sending: "Отправляем…",
|
||||||
|
doneTitle: "Готово",
|
||||||
|
doneLead: "Врач увидит отметку.",
|
||||||
|
breathe: "Вдох на 4 — выдох на 6",
|
||||||
|
breatheIn: "Вдох",
|
||||||
|
breatheOut: "Выдох",
|
||||||
|
openPlan: "Открыть мой план безопасности",
|
||||||
|
supportTitle: "Вы не одни",
|
||||||
|
exit: "К главной",
|
||||||
|
planTitle: "Мой план безопасности",
|
||||||
|
consentTitle: "Согласие на обработку данных о здоровье",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
title: "Сообщения",
|
||||||
inputLabel: "Сообщение",
|
inputLabel: "Сообщение",
|
||||||
inputPlaceholder: "Напишите сообщение",
|
inputPlaceholder: "Напишите сообщение",
|
||||||
send: "Отправить",
|
|
||||||
loading: "Подключаемся…",
|
|
||||||
sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.",
|
|
||||||
you: "Вы",
|
you: "Вы",
|
||||||
assistant: "Помощник",
|
empty: "Здесь будут сообщения помощника.",
|
||||||
crisisLabel: "Важно",
|
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
|
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
|
||||||
|
|||||||
@@ -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/400.css";
|
||||||
import "@fontsource/golos-text/500.css";
|
import "@fontsource/golos-text/500.css";
|
||||||
import "@fontsource/golos-text/600.css";
|
import "@fontsource/golos-text/600.css";
|
||||||
|
import "@fontsource/golos-text/700.css";
|
||||||
import "./theme/tokens.css";
|
import "./theme/tokens.css";
|
||||||
import "./theme/global.css";
|
import "./theme/global.css";
|
||||||
|
|
||||||
import { StrictMode } from "react";
|
import { StrictMode, type ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { HelpProvider } from "./components/HelpSheet";
|
||||||
|
import { DialogProvider } from "./dialog/DialogContext";
|
||||||
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
|
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
|
||||||
import { Chat } from "./screens/Chat";
|
import { Doctor } from "./screens/Doctor";
|
||||||
import { EmiasSignIn } from "./screens/EmiasSignIn";
|
import { EmiasSignIn } from "./screens/EmiasSignIn";
|
||||||
|
import { Flow } from "./screens/Flow";
|
||||||
|
import { DoseReminder, Meds } from "./screens/Meds";
|
||||||
|
import { Messages } from "./screens/Messages";
|
||||||
|
import { Profile } from "./screens/Profile";
|
||||||
|
import { Today } from "./screens/Today";
|
||||||
import { Welcome } from "./screens/Welcome";
|
import { Welcome } from "./screens/Welcome";
|
||||||
import { SessionProvider, useSession } from "./session/SessionContext";
|
import { SessionProvider, useSession } from "./session/SessionContext";
|
||||||
|
|
||||||
|
function Private({ children }: { children: ReactNode }) {
|
||||||
|
const { session } = useSession();
|
||||||
|
return session ? <>{children}</> : <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
|
// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={session ? <Navigate to="/chat" replace /> : <Welcome />} />
|
<Route path="/" element={session ? <Navigate to="/today" replace /> : <Welcome />} />
|
||||||
<Route path="/emias" element={<EmiasSignIn />} />
|
<Route path="/emias" element={<EmiasSignIn />} />
|
||||||
<Route path="/anonymous" element={<AnonymousSignIn />} />
|
<Route path="/anonymous" element={<AnonymousSignIn />} />
|
||||||
<Route path="/chat" element={<Chat />} />
|
<Route path="/flow" element={<Private><Flow /></Private>} />
|
||||||
|
<Route path="/today" element={<Private><Today /></Private>} />
|
||||||
|
<Route path="/dose/:id" element={<Private><DoseReminder /></Private>} />
|
||||||
|
<Route path="/meds" element={<Private><Meds /></Private>} />
|
||||||
|
<Route path="/doctor" element={<Private><Doctor /></Private>} />
|
||||||
|
<Route path="/profile" element={<Private><Profile /></Private>} />
|
||||||
|
<Route path="/messages" element={<Private><Messages /></Private>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -31,9 +50,13 @@ function AppRoutes() {
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<HashRouter>
|
<DialogProvider>
|
||||||
<AppRoutes />
|
<HelpProvider>
|
||||||
</HashRouter>
|
<HashRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</HashRouter>
|
||||||
|
</HelpProvider>
|
||||||
|
</DialogProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { AppShell, BackLink, Notice } from "../components/AppShell";
|
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon, type IconName } from "../components/Icon";
|
||||||
import { ru } from "../content/ru";
|
import { ru } from "../content/ru";
|
||||||
import { useSession } from "../session/SessionContext";
|
import { useSession } from "../session/SessionContext";
|
||||||
|
|
||||||
|
const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
|
||||||
|
const NO_ICONS: IconName[] = ["user", "doc"];
|
||||||
|
|
||||||
|
// A3 · Анонимный вход
|
||||||
export function AnonymousSignIn() {
|
export function AnonymousSignIn() {
|
||||||
const t = ru.anonymous;
|
const t = ru.anonymous;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,7 +23,7 @@ export function AnonymousSignIn() {
|
|||||||
setError(false);
|
setError(false);
|
||||||
try {
|
try {
|
||||||
signIn(await api.signInAnonymous());
|
signIn(await api.signInAnonymous());
|
||||||
navigate("/chat", { replace: true });
|
navigate("/flow", { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -27,35 +31,55 @@ export function AnonymousSignIn() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell signedIn={false} focus>
|
||||||
<main className="page" id="main">
|
<div className="stack narrow">
|
||||||
<div className="narrow">
|
<TopBar onBack={() => navigate("/")} />
|
||||||
<BackLink to="/">{t.back}</BackLink>
|
<header className="head">
|
||||||
<section className="panel" aria-labelledby="anon-title">
|
<h1 className="h1">{t.title}</h1>
|
||||||
<h1 id="anon-title" className="h2">
|
<p className="lead">{t.lead}</p>
|
||||||
{t.title}
|
</header>
|
||||||
</h1>
|
|
||||||
<p className="lead lead--sm">{t.lead}</p>
|
<section className="panel panel--teal">
|
||||||
<ul className="facts facts--spaced">
|
<p className="panel__title">
|
||||||
{t.points.map((p) => (
|
<span className="badge-icon badge-icon--teal">
|
||||||
<li key={p} className="fact">
|
<Icon name="check" size={16} />
|
||||||
<Icon name="check" size={18} />
|
</span>
|
||||||
<span>{p}</span>
|
{t.yesTitle}
|
||||||
</li>
|
</p>
|
||||||
))}
|
<ul className="iconlist">
|
||||||
</ul>
|
{t.yes.map((item, i) => (
|
||||||
<Notice tone="warning">{t.warning}</Notice>
|
<li key={item}>
|
||||||
{error && (
|
<Icon name={YES_ICONS[i]} size={20} />
|
||||||
<p className="field__error" role="alert">
|
{item}
|
||||||
{ru.emias.networkError}
|
</li>
|
||||||
</p>
|
))}
|
||||||
)}
|
</ul>
|
||||||
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}>
|
</section>
|
||||||
{busy ? t.submitting : t.submit}
|
|
||||||
</button>
|
<section className="panel panel--red">
|
||||||
</section>
|
<p className="panel__title">
|
||||||
</div>
|
<span className="badge-icon badge-icon--red">
|
||||||
</main>
|
<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>
|
</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 { ApiError, api } from "../api/client";
|
||||||
import type { AuthResult, DemoPersona } from "../api/types";
|
import type { AuthResult, DemoPersona } from "../api/types";
|
||||||
import { AppShell, BackLink, Notice } from "../components/AppShell";
|
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
import { ru } from "../content/ru";
|
import { ru } from "../content/ru";
|
||||||
import { useSession } from "../session/SessionContext";
|
import { useSession } from "../session/SessionContext";
|
||||||
|
|
||||||
|
// A2 · Вход через ЕМИАС (демо: одноразовый код от врача или синтетический пациент)
|
||||||
export function EmiasSignIn() {
|
export function EmiasSignIn() {
|
||||||
const t = ru.emias;
|
const t = ru.emias;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,11 +27,11 @@ export function EmiasSignIn() {
|
|||||||
|
|
||||||
const finish = (auth: AuthResult) => {
|
const finish = (auth: AuthResult) => {
|
||||||
signIn(auth);
|
signIn(auth);
|
||||||
navigate("/chat", { replace: true });
|
navigate("/flow", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const fail = (e: unknown) => {
|
const fail = (e: unknown) => {
|
||||||
setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
|
setError(e instanceof ApiError && e.status === 401 ? t.codeError : ru.common.networkError);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,81 +58,64 @@ export function EmiasSignIn() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell signedIn={false} focus>
|
||||||
<main className="page" id="main">
|
<div className="stack narrow">
|
||||||
<div className="narrow">
|
<TopBar onBack={() => navigate("/")} />
|
||||||
<BackLink to="/">{t.back}</BackLink>
|
<header className="head">
|
||||||
<section className="panel" aria-labelledby="emias-title">
|
<h1 className="h1">{t.title}</h1>
|
||||||
<h1 id="emias-title" className="h2">
|
<p className="lead">{t.lead}</p>
|
||||||
{t.title}
|
</header>
|
||||||
</h1>
|
|
||||||
<Notice>{t.demoNotice}</Notice>
|
|
||||||
|
|
||||||
<form className="form" onSubmit={submit} noValidate>
|
<form className="stack stack--sm" onSubmit={submit} noValidate>
|
||||||
<div className={error ? "field field--error" : "field"}>
|
<label htmlFor="code" className="visually-hidden">
|
||||||
<label htmlFor="code" className="field__label">
|
{t.codeLabel}
|
||||||
{t.codeLabel}
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
id="code"
|
||||||
id="code"
|
className={error ? "code-input is-error" : "code-input"}
|
||||||
className="input"
|
value={code}
|
||||||
value={code}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
placeholder={t.codePlaceholder}
|
||||||
placeholder={t.codePlaceholder}
|
autoComplete="one-time-code"
|
||||||
autoComplete="one-time-code"
|
autoCapitalize="characters"
|
||||||
autoCapitalize="characters"
|
spellCheck={false}
|
||||||
spellCheck={false}
|
maxLength={32}
|
||||||
maxLength={32}
|
aria-invalid={error ? true : undefined}
|
||||||
aria-invalid={error ? true : undefined}
|
aria-describedby={error ? "code-error" : undefined}
|
||||||
aria-describedby={error ? "code-error" : "code-hint"}
|
/>
|
||||||
/>
|
{error && (
|
||||||
{error ? (
|
<div id="code-error" role="alert">
|
||||||
<p id="code-error" className="field__error" role="alert">
|
<Notice tone="danger">{error}</Notice>
|
||||||
{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>
|
|
||||||
</div>
|
</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>
|
<Notice>{t.demoNotice}</Notice>
|
||||||
<p className="muted">{t.demoLead}</p>
|
|
||||||
{personas === null ? null : personas.length === 0 ? (
|
<div className="divider" role="separator">
|
||||||
<p className="muted">{t.demoEmpty}</p>
|
<span>{t.or}</span>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</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 { Icon } from "../components/Icon";
|
||||||
import { ru } from "../content/ru";
|
import { ru } from "../content/ru";
|
||||||
|
|
||||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
// A1 · Выбор способа входа
|
||||||
const WEEK = [38, 52, 46, 60, 58, 72, 80]; // illustrative trend, %
|
|
||||||
|
|
||||||
function Facts({ items }: { items: readonly { ok: boolean; text: string }[] }) {
|
|
||||||
return (
|
|
||||||
<ul className="facts">
|
|
||||||
{items.map((f) => (
|
|
||||||
<li key={f.text} className={f.ok ? "fact" : "fact fact--no"}>
|
|
||||||
<Icon name={f.ok ? "check" : "minus"} size={18} />
|
|
||||||
<span>{f.text}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Illustration of a day with the service (decorative: the same facts are stated in text).
|
|
||||||
function DayPreview() {
|
|
||||||
const d = ru.welcome.day;
|
|
||||||
return (
|
|
||||||
<figure className="day" aria-hidden="true">
|
|
||||||
<figcaption className="day__caption">{d.caption}</figcaption>
|
|
||||||
<ol className="day__list">
|
|
||||||
<li className="day__item day__item--meds">
|
|
||||||
<span className="day__time">{d.meds.time}</span>
|
|
||||||
<div className="day__card">
|
|
||||||
<span className="day__track">
|
|
||||||
<Icon name="pill" size={16} />
|
|
||||||
{d.meds.track}
|
|
||||||
</span>
|
|
||||||
<div className="day__row">
|
|
||||||
<span className="day__text">{d.meds.text}</span>
|
|
||||||
<span className="day__done">
|
|
||||||
<Icon name="check" size={16} />
|
|
||||||
{d.meds.done}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="day__item day__item--mood">
|
|
||||||
<span className="day__time">{d.mood.time}</span>
|
|
||||||
<div className="day__card">
|
|
||||||
<span className="day__track">
|
|
||||||
<Icon name="pulse" size={16} />
|
|
||||||
{d.mood.track}
|
|
||||||
</span>
|
|
||||||
<span className="day__text">{d.mood.text}</span>
|
|
||||||
<div className="scale">
|
|
||||||
{SCALE.map((n) => (
|
|
||||||
<span key={n} className={`scale__dot scale__dot--${n}${n === 4 ? " is-picked" : ""}`}>
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="scale__ends">
|
|
||||||
<span>{d.mood.low}</span>
|
|
||||||
<span>{d.mood.high}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="day__item day__item--doctor">
|
|
||||||
<span className="day__time">{d.doctor.time}</span>
|
|
||||||
<div className="day__card">
|
|
||||||
<span className="day__track">
|
|
||||||
<Icon name="doctor" size={16} />
|
|
||||||
{d.doctor.track}
|
|
||||||
</span>
|
|
||||||
<div className="day__row">
|
|
||||||
<span className="day__text">{d.doctor.text}</span>
|
|
||||||
<span className="spark">
|
|
||||||
{WEEK.map((v, i) => (
|
|
||||||
<i key={i} style={{ height: `${v}%` }} />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Welcome() {
|
export function Welcome() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const t = ru.welcome;
|
const t = ru.welcome;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell signedIn={false}>
|
||||||
<main className="welcome" id="main">
|
<div className="stack">
|
||||||
<div className="welcome__inner">
|
<header className="head">
|
||||||
<section className="welcome__intro" aria-labelledby="welcome-title">
|
<h1 className="h1">{t.title}</h1>
|
||||||
<h1 id="welcome-title" className="display">
|
<p className="lead">{t.lead}</p>
|
||||||
{t.title}
|
</header>
|
||||||
</h1>
|
|
||||||
<p className="lead">{t.lead}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="signin" aria-labelledby="choice-title">
|
<div className="entry-grid">
|
||||||
<h2 id="choice-title" className="signin__title">
|
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
|
||||||
{t.choose}
|
<span className="entry__top">
|
||||||
</h2>
|
<span className="tile-icon tile-icon--teal">
|
||||||
|
<Icon name="docPlus" size={26} />
|
||||||
|
</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">
|
<button type="button" className="entry" onClick={() => navigate("/anonymous")}>
|
||||||
<div className="option__head">
|
<span className="entry__top">
|
||||||
<span className="option__icon">
|
<span className="tile-icon">
|
||||||
<Icon name="doctor" size={22} />
|
<Icon name="lock" size={26} />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="option__title">{t.emias.title}</h3>
|
</span>
|
||||||
<span className="badge">{t.emias.badge}</span>
|
<span className="entry__title">
|
||||||
</div>
|
{t.anonymous.title}
|
||||||
<Facts items={t.emias.facts} />
|
<Icon name="chevronRight" />
|
||||||
<button type="button" className="btn btn--primary btn--block" onClick={() => navigate("/emias")}>
|
</span>
|
||||||
{t.emias.action}
|
<span className="entry__text">{t.anonymous.text}</span>
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
<p className="muted small">{t.disclaimer}</p>
|
||||||
|
</div>
|
||||||
</AppShell>
|
</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 {
|
interface SessionApi {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
signIn: (auth: AuthResult) => void;
|
signIn: (auth: AuthResult) => void;
|
||||||
signOut: () => void;
|
/** Leaves the account. Anonymous history is always wiped: it cannot be restored anyway. */
|
||||||
|
signOut: (opts?: { wipe?: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ctx = createContext<SessionApi | null>(null);
|
const Ctx = createContext<SessionApi | null>(null);
|
||||||
@@ -20,9 +21,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
setSession(next);
|
setSession(next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = useCallback(() => {
|
const signOut = useCallback((opts?: { wipe?: boolean }) => {
|
||||||
setSession((current) => {
|
setSession((current) => {
|
||||||
sessionStore.clear(current);
|
sessionStore.clear(current, opts?.wipe ?? current?.mode === "anonymous");
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ export interface Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ChatEntry =
|
export type ChatEntry =
|
||||||
| { id: string; from: "assistant"; reply: Reply; answered: boolean }
|
| { id: string; from: "assistant"; reply: Reply }
|
||||||
| { id: string; from: "patient"; text: string };
|
| { id: string; from: "patient"; text: string };
|
||||||
|
|
||||||
|
/** Dialog snapshot per token: full history + the latest unanswered batch of engine replies. */
|
||||||
|
export interface DialogSnapshot {
|
||||||
|
entries: ChatEntry[];
|
||||||
|
pending: Reply[];
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface KeyValue {
|
interface KeyValue {
|
||||||
get(key: string): string | null;
|
get(key: string): string | null;
|
||||||
set(key: string, value: string): void;
|
set(key: string, value: string): void;
|
||||||
@@ -46,7 +53,8 @@ const browserStorage: KeyValue = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SESSION_KEY = "ptsr.session.v1";
|
const SESSION_KEY = "ptsr.session.v1";
|
||||||
const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
|
const dialogKey = (token: string) => `ptsr.dialog.v2.${token}`;
|
||||||
|
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
|
||||||
|
|
||||||
function read<T>(storage: KeyValue, key: string): T | null {
|
function read<T>(storage: KeyValue, key: string): T | null {
|
||||||
const raw = storage.get(key);
|
const raw = storage.get(key);
|
||||||
@@ -62,12 +70,12 @@ export function createSessionStore(storage: KeyValue = browserStorage) {
|
|||||||
return {
|
return {
|
||||||
load: () => read<Session>(storage, SESSION_KEY),
|
load: () => read<Session>(storage, SESSION_KEY),
|
||||||
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
|
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
|
||||||
clear: (s: Session | null) => {
|
clear: (s: Session | null, wipeDialog = true) => {
|
||||||
storage.remove(SESSION_KEY);
|
storage.remove(SESSION_KEY);
|
||||||
if (s) storage.remove(chatKey(s.token));
|
if (s && wipeDialog) storage.remove(dialogKey(s.token));
|
||||||
},
|
},
|
||||||
loadChat: (token: string) => read<ChatEntry[]>(storage, chatKey(token)) ?? [],
|
loadDialog: (token: string): DialogSnapshot => ({ ...EMPTY, ...read<DialogSnapshot>(storage, dialogKey(token)) }),
|
||||||
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
|
saveDialog: (token: string, snap: DialogSnapshot) => storage.set(dialogKey(token), JSON.stringify(snap)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.
|
* Design tokens — single source of colours, type and spacing (docs/mock, series 2).
|
||||||
* When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a
|
* Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object.
|
||||||
* theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
|
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
/* Base: cool ink on cool white. */
|
/* Ink & surfaces */
|
||||||
--c-ink: #13233a;
|
--c-navy: #0f2747; /* primary buttons, headings, desktop sidebar */
|
||||||
--c-text: #13233a;
|
--c-navy-hover: #0a1d37;
|
||||||
--c-text-2: #46566b;
|
--c-navy-soft: #e8eef6;
|
||||||
--c-text-3: #6b7a8e;
|
--c-text: #13294b;
|
||||||
--c-page: #f4f7fb;
|
--c-text-2: #4d5d73;
|
||||||
|
--c-text-3: #7a889b;
|
||||||
|
--c-page: #ffffff;
|
||||||
|
--c-page-desk: #f4f7fb; /* desktop canvas behind white panels */
|
||||||
--c-surface: #ffffff;
|
--c-surface: #ffffff;
|
||||||
--c-band: #e9f0fb; /* solid tint behind the welcome hero */
|
--c-tile: #f3f7fb; /* soft card fill */
|
||||||
--c-border: #dbe3ee;
|
--c-border: #e2e9f1;
|
||||||
--c-border-strong: #b3c0d1;
|
--c-border-strong: #c9d4e1;
|
||||||
|
|
||||||
/* Care tracks: one colour per thing the service does. The same colour marks the track everywhere
|
/* Selection & positive: teal */
|
||||||
(welcome preview, chat questions), so colour carries meaning, never decoration. */
|
--c-teal: #17a08f;
|
||||||
--c-meds: #0e8c7f;
|
--c-teal-strong: #0e7e71;
|
||||||
--c-meds-soft: #e2f4f1;
|
--c-teal-soft: #e3f5f2;
|
||||||
--c-mood: #e79a0c;
|
|
||||||
--c-mood-soft: #fdf1da;
|
|
||||||
--c-mood-ink: #8a5800; /* text on mood-soft (AA) */
|
|
||||||
--c-doctor: #2350c8;
|
|
||||||
--c-doctor-soft: #e7eefc;
|
|
||||||
|
|
||||||
/* Action colour = the doctor/ЕМИАС blue. */
|
/* Info: blue */
|
||||||
--c-primary: #2350c8;
|
--c-blue: #2f7fdb;
|
||||||
--c-primary-hover: #1c43ac;
|
--c-blue-soft: #eaf3fd;
|
||||||
--c-primary-active: #163689;
|
|
||||||
--c-primary-soft: #e7eefc;
|
|
||||||
|
|
||||||
/* Mood scale 1 → 5 (heavy → good); used only for the scale. */
|
/* Help & crisis: red */
|
||||||
--c-scale-1: #d9485f;
|
--c-red: #e2383b;
|
||||||
--c-scale-2: #ef8a3c;
|
--c-red-strong: #c62b2e;
|
||||||
--c-scale-3: #f2c230;
|
--c-red-soft: #fdeded;
|
||||||
--c-scale-4: #7cc35b;
|
--c-red-line: #f7d3d3;
|
||||||
--c-scale-5: #2fa36b;
|
|
||||||
|
|
||||||
--c-danger: #c8283a;
|
/* Warning */
|
||||||
--c-danger-hover: #a91f2f;
|
--c-amber: #e69a12;
|
||||||
--c-danger-soft: #fdebed;
|
--c-amber-soft: #fff4dd;
|
||||||
--c-success: #1e8a55;
|
|
||||||
--c-warning-soft: #fff4dc;
|
|
||||||
--c-warning-border: #e7b448;
|
|
||||||
--c-focus: #3b82f6;
|
|
||||||
|
|
||||||
/* Type: Golos Text — Russian open-source UI typeface (OFL), bundled locally so the app works offline. */
|
--c-focus: #2f7fdb;
|
||||||
|
|
||||||
|
/* Type: Golos Text (OFL), bundled locally — works offline. */
|
||||||
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
|
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
--fs-12: 0.75rem;
|
--fs-12: 0.75rem;
|
||||||
|
--fs-13: 0.8125rem;
|
||||||
--fs-14: 0.875rem;
|
--fs-14: 0.875rem;
|
||||||
--fs-16: 1rem;
|
--fs-16: 1rem;
|
||||||
--fs-18: 1.125rem;
|
--fs-18: 1.125rem;
|
||||||
--fs-22: 1.375rem;
|
--fs-20: 1.25rem;
|
||||||
|
--fs-24: 1.5rem;
|
||||||
--fs-28: 1.75rem;
|
--fs-28: 1.75rem;
|
||||||
--fs-40: 2.5rem;
|
--fs-34: 2.125rem;
|
||||||
--lh-tight: 1.15;
|
--fs-48: 3rem;
|
||||||
--lh-text: 1.5;
|
|
||||||
|
|
||||||
/* Spacing: 4 px grid. */
|
/* Spacing: 4 px grid */
|
||||||
--s-1: 4px;
|
--s-1: 4px;
|
||||||
--s-2: 8px;
|
--s-2: 8px;
|
||||||
--s-3: 12px;
|
--s-3: 12px;
|
||||||
@@ -68,19 +61,17 @@
|
|||||||
--s-8: 32px;
|
--s-8: 32px;
|
||||||
--s-10: 40px;
|
--s-10: 40px;
|
||||||
--s-12: 48px;
|
--s-12: 48px;
|
||||||
--s-16: 64px;
|
|
||||||
|
|
||||||
/* Radius hierarchy: containers > controls > fields > chips. */
|
/* Radii: tiles > buttons > chips */
|
||||||
--r-card: 14px;
|
--r-tile: 16px;
|
||||||
--r-control: 10px;
|
--r-btn: 12px;
|
||||||
--r-field: 8px;
|
--r-chip: 999px;
|
||||||
--r-chip: 6px;
|
|
||||||
|
|
||||||
--tap: 48px; /* minimum touch target */
|
--tap: 52px;
|
||||||
--header-h: 64px;
|
--nav-h: 64px;
|
||||||
--content-max: 1160px;
|
--sidebar-w: 232px;
|
||||||
|
--rail-w: 300px;
|
||||||
|
|
||||||
/* Safe areas (notch, home indicator) for the mobile shell. */
|
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
--safe-left: env(safe-area-inset-left, 0px);
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
|||||||
Reference in New Issue
Block a user