feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile
- /flow renders the dialog engine's current step as a mock screen (consent,
age, onboarding and scales with progress, mood 0–10, free text, dose
reminder, crisis with hotlines and breathing, safety plan), so every
engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
prescription from the dashboard fixtures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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