diff --git a/app/api/patient_web.py b/app/api/patient_web.py index 129f975..3e58ec5 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -16,14 +16,19 @@ issued by the server instead of handing out the pseudonymous token itself. import logging import secrets +from collections import defaultdict +from datetime import date, datetime, time, timedelta from typing import Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select from app.api import fixtures +from app.core import clock from app.core.safety import CrisisResource, crisis_reply from app.db import repo +from app.db.clinical import DailyCheckin, Dose, Prescription from app.db.identity import TOKEN_LEN from app.db.session import get_clinical_session from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock @@ -56,6 +61,43 @@ async def _attach_doctor(token: str, doctor_id: str) -> None: await repo.upsert_profile(s, token=token, doctor_id=doctor_id) +async def _seed_demo_persona(token: str) -> None: + """Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses. + + Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the + doctor's card tell the same story. + """ + card = fixtures.card(token) + item = next((p for p in fixtures.PATIENTS if p.token == token), None) + if card is None or item is None: + return + now = clock.now() + today = now.date() + async with get_clinical_session() as s: + if await repo.get_profile(s, token) is None: + await repo.upsert_profile( + s, + token=token, + doctor_id=fixtures.DOCTOR_ID, + modules=list(item.modules), + therapy_start=today, + next_visit=today + timedelta(days=15), + ) + if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None: + return + for p in card.prescriptions: + rx = Prescription(token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=today) + s.add(rx) + await s.flush() + for t in p.schedule.get("times", []): + hh, mm = (int(x) for x in t.split(":")) + s.add( + Dose( + token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo) + ) + ) + + @router.post("/auth/emias", response_model=AuthOut) async def auth_emias(body: EmiasCodeIn) -> AuthOut: """Sign-in through ЕМИАС (demo: one-time connect code from the doctor).""" @@ -74,7 +116,7 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut: persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None) if persona is None: raise HTTPException(404, "persona not found") - await _attach_doctor(persona.token, fixtures.DOCTOR_ID) + await _seed_demo_persona(persona.token) log.info("web auth: emias demo tok=%s", persona.token[:6]) return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name) @@ -87,6 +129,97 @@ async def auth_anonymous() -> AuthOut: return AuthOut(token=token, mode="anonymous") +class DoseItem(BaseModel): + id: int + drug: str + dose: str + time: str # HH:MM, patient's local time + status: Literal["taken", "missed", "skipped", "pending"] + + +class WeekDay(BaseModel): + day: date + status: Literal["taken", "missed", "pending", "none"] + + +class PrescriptionItem(BaseModel): + drug: str + dose: str + times: list[str] + week: list[WeekDay] # Mon..Sun of the current week + + +class TodayOut(BaseModel): + today: date + now: str # HH:MM on the (demo) clock — the client never uses its own clock + has_doctor: bool + checkin_done: bool + next_visit: date | None + doses: list[DoseItem] + prescriptions: list[PrescriptionItem] + + +def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]: + if not statuses: + return "none" + if any(s in ("missed", "skipped") for s in statuses): + return "missed" + return "taken" if all(s == "taken" for s in statuses) else "pending" + + +@router.get("/me/{token}/today", response_model=TodayOut) +async def my_today(token: str) -> TodayOut: + """The patient's own plan for today (home screen) and this week's dose marks (medication screen). + + Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan. + """ + now = clock.now() + today = now.date() + week_start = today - timedelta(days=today.weekday()) + async with get_clinical_session() as s: + profile = await repo.get_profile(s, token) + prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all() + doses = ( + await s.scalars( + select(Dose) + .where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo)) + .order_by(Dose.planned_at) + ) + ).all() + checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today)) + + by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions} + today_doses: list[DoseItem] = [] + marks: dict[tuple[int, date], list[str]] = defaultdict(list) + for d in doses: + local = d.planned_at.astimezone(now.tzinfo) + marks[(d.prescription_id, local.date())].append(d.status) + rx = by_rx.get(d.prescription_id) + if local.date() == today and rx is not None: + today_doses.append( + DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status) + ) + week = [week_start + timedelta(days=i) for i in range(7)] + return TodayOut( + today=today, + now=now.strftime("%H:%M"), + has_doctor=profile is not None, + checkin_done=checkin is not None and checkin.mood is not None, + next_visit=profile.next_visit if profile else None, + doses=today_doses, + prescriptions=[ + PrescriptionItem( + drug=p.drug, + dose=p.dose, + times=list((p.schedule or {}).get("times", [])), + week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week], + ) + for p in prescriptions + if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today) + ], + ) + + @router.get("/crisis/resources", response_model=list[CrisisResource]) async def crisis_resources() -> list[CrisisResource]: """Help lines for the always-visible «help now» button — the same list as the fixed crisis script.""" diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index e8da9eb..68db13e 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -1,6 +1,7 @@ """Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine.""" from collections.abc import AsyncIterator +from datetime import datetime, time, timedelta import pytest from httpx import ASGITransport, AsyncClient @@ -9,6 +10,7 @@ from app.api import fixtures from app.core import clock from app.db import identity_repo, repo, session from app.db.bootstrap import init_db +from app.db.clinical import Dose, Prescription from app.main import app MEM = "sqlite+aiosqlite:///:memory:" @@ -81,6 +83,37 @@ async def test_emias_demo_persona(client: AsyncClient) -> None: assert missing.status_code == 404 +async def test_today_plan_and_week_marks(client: AsyncClient) -> None: + now = clock.now() + today = now.date() + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5)) + rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]}) + s.add(rx) + await s.flush() + morning = datetime.combine(today, time(9, 0), now.tzinfo) + s.add_all( + [ + Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"), + Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)), + ] + ) + await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6) + + body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json() + assert body["has_doctor"] and body["checkin_done"] + assert body["next_visit"] == (today + timedelta(days=5)).isoformat() + assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")] + week = body["prescriptions"][0]["week"] + assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending" + + +async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous")).json()["token"] + body = (await client.get(f"/api/v1/me/{token}/today")).json() + assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == [] + + async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()] assert "112" in phones diff --git a/web/.gitignore b/web/.gitignore index f4e2c6d..e4cd754 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tsbuildinfo +.vite/ diff --git a/web/README.md b/web/README.md index db2e6a3..d235068 100644 --- a/web/README.md +++ b/web/README.md @@ -30,9 +30,20 @@ src/theme/tokens.css цвета, шрифт, отступы — единств src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок) src/api/ типы-зеркала контрактов бэкенда и клиент src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain -src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat +src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts) +src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4), + «Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1 ``` +### Как устроены экраны + +Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его +как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст, +напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда. + +Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю, +следующий визит, время по демо-часам). + Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API). diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c512b5c..aeaeb2a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types"; +import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types"; // Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build. const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1"; @@ -35,6 +35,7 @@ export const api = { signInAnonymous: () => post("/auth/anonymous"), demoPersonas: () => request("/demo/personas"), crisisResources: () => request("/crisis/resources"), + today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`), sendEvent: async (token: string, event: DialogEvent) => (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, outbox: async (token: string) => diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1e40513..1786f60 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -39,3 +39,30 @@ export interface DemoPersona { therapy_day: number; scenario: string | null; } + +export type DoseStatus = "taken" | "missed" | "skipped" | "pending"; + +export interface TodayDose { + id: number; + drug: string; + dose: string; + time: string; + status: DoseStatus; +} + +export interface TodayPrescription { + drug: string; + dose: string; + times: string[]; + week: { day: string; status: "taken" | "missed" | "pending" | "none" }[]; +} + +export interface TodayPlan { + today: string; + now: string; + has_doctor: boolean; + checkin_done: boolean; + next_visit: string | null; + doses: TodayDose[]; + prescriptions: TodayPrescription[]; +} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 42dbd69..10dd890 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,64 +1,176 @@ -import { useState, type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import type { ReactNode } from "react"; +import { Link, NavLink } from "react-router-dom"; import { ru } from "../content/ru"; -import { HelpSheet } from "./HelpSheet"; -import { Icon, LogoMark } from "./Icon"; +import { useDialog } from "../dialog/DialogContext"; +import { HotlineCard, useHelp } from "./HelpSheet"; +import { Icon, LogoMark, type IconName } from "./Icon"; -export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) { - const [helpOpen, setHelpOpen] = useState(false); +/* + * Layout from docs/mock (series 2). + * phone: content → «Экстренная помощь» block → bottom tab bar (signed in) + * desktop: navy sidebar | content | right rail (optional `aside` + emergency panel) + */ +const TABS: { to: string; label: string; icon: IconName }[] = [ + { to: "/today", label: ru.nav.today, icon: "home" }, + { to: "/meds", label: ru.nav.meds, icon: "pill" }, + { to: "/doctor", label: ru.nav.doctor, icon: "doctor" }, + { to: "/profile", label: ru.nav.profile, icon: "user" }, +]; + +/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */ +export function EmergencyBlock() { + const { resources, open } = useHelp(); return ( -
-
-
- - - - {ru.product.name} - {ru.product.tagline} - - - -
-
+
+ +
+ {resources.slice(0, 2).map((r) => ( + + ))} +
+
+ ); +} - {children} - - {footer && ( -
-
-

- - {ru.footer.law} -

-

{ru.footer.prototype}

-
-
+function Sidebar({ signedIn }: { signedIn: boolean }) { + const { open } = useHelp(); + const { unread } = useDialog(); + return ( + + ); +} - setHelpOpen(false)} /> +function BottomNav() { + return ( + + ); +} + +function Rail({ aside }: { aside?: ReactNode }) { + const { resources, open } = useHelp(); + return ( + + ); +} + +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 ( +
+ +
+
+ {children} + {sos && } +
+ +
+ {signedIn && !focus && }
); } -export function BackLink({ to, children }: { to: string; children: ReactNode }) { - return ( - - - {children} - - ); -} - -export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) { +export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) { + const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert"; return (
- -

{children}

+ +
{children}
+
+ ); +} + +export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) { + return ( +
+ {onBack && ( + + )} + {title && {title}} + {children}
); } diff --git a/web/src/components/HelpSheet.tsx b/web/src/components/HelpSheet.tsx index b112a9f..7c82bda 100644 --- a/web/src/components/HelpSheet.tsx +++ b/web/src/components/HelpSheet.tsx @@ -1,18 +1,27 @@ -import { useEffect, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { api } from "../api/client"; import type { CrisisResource } from "../api/types"; import { ru } from "../content/ru"; import { Icon } from "./Icon"; -// 112 is shown even when the API is unreachable: the help sheet must never be empty. -const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }]; +// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»). +const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }]; -const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; +export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; -export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) { - const ref = useRef(null); +interface HelpApi { + resources: CrisisResource[]; + open: () => void; +} + +const Ctx = createContext({ 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(FALLBACK); + const [isOpen, setOpen] = useState(false); useEffect(() => { api @@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi .catch(() => undefined); }, []); + const open = useCallback(() => setOpen(true), []); + return ( + + {children} + setOpen(false)} resources={resources} /> + + ); +} + +export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) { + const emergency = r.phone.replace(/\D/g, "").length <= 3; + return ( + + + + + + {emergency ? ( + <> + {r.phone} + {r.name} + + ) : ( + <> + {r.name} + {r.phone} + + )} + {r.note && size === "lg" && {r.note}} + + {size === "lg" && } + + ); +} + +function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) { + const ref = useRef(null); + useEffect(() => { const dialog = ref.current; if (!dialog) return; @@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi onClick={(e) => e.target === ref.current && onClose()} >
-
-

- {ru.help.title} -

+
+

+ {ru.help.title} +

-

{ru.help.lead}

- +
+

+ + {ru.help.alwaysNote} +

); diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index d9279ec..8ecd5d1 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -1,14 +1,37 @@ -// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers. +// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers. const paths = { - check: , - minus: , - phone: ( - + home: , + pill: ( + <> + + + ), + doctor: ( + <> + + + + + ), + user: ( + <> + + + + ), + bell: ( + <> + + + + ), + chevronRight: , arrowLeft: , - arrowRight: , - send: , + close: , + check: , + x: , info: ( <> @@ -21,33 +44,88 @@ const paths = { ), + phone: ( + + ), + headset: ( + <> + + + + + ), + calendar: ( + <> + + + + ), + clock: ( + <> + + + + ), + pin: ( + <> + + + + ), + smile: ( + <> + + + + ), + doc: ( + <> + + + + ), + docPlus: ( + <> + + + + ), + shield: ( + <> + + + + ), lock: ( <> - + ), - pill: ( - <> - - - - ), - pulse: , - doctor: ( - <> - - - - - ), - close: , + edit: , + trash: , logout: , + send: , + heart: , + lungs: ( + <> + + + + ), + question: ( + <> + + + + ), + message: , + plus: , } as const; export type IconName = keyof typeof paths; -export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) { +export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) { return ( ); diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index e8b851f..89b9a83 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -1,100 +1,165 @@ /* - * All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/ - * for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here. + * All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales, + * reminders — comes from the engine (config/texts/) and is NOT duplicated here. */ export const ru = { product: { name: "Сопровождение пациента", tagline: "между визитами к врачу", - prototype: "Прототип · синтетические данные", + }, + nav: { + today: "Сегодня", + meds: "Лекарства", + doctor: "Врач", + profile: "Профиль", + messages: "Сообщения", + signIn: "Вход", + help: "Помощь", }, help: { - button: "Нужна помощь", - title: "Если вам сейчас очень тяжело", - lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:", + block: "Экстренная помощь", + title: "Экстренная помощь", + lead: "Если вам нужна срочная помощь, позвоните сразу.", call: "Позвонить", close: "Закрыть", - fallbackName: "Экстренные службы", - fallbackNote: "если есть угроза жизни прямо сейчас", + alwaysNote: "Вы не одни. Помощь доступна круглосуточно.", + fallbackName: "Единый номер экстренных служб", + fallbackNote: "", + }, + common: { + back: "Назад", + next: "Далее", + skip: "Пропустить", + send: "Отправить", + retry: "Повторить", + toToday: "Вернуться на сегодня", + recommended: "Рекомендуем", + networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.", }, welcome: { - title: "Лечение продолжается и между приёмами", - lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.", - day: { - caption: "Обычный день с сервисом", - meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" }, - mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" }, - doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" }, - }, - choose: "Как вы хотите войти?", + title: "Как вы хотите войти в приложение?", + lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.", emias: { title: "Через ЕМИАС", - badge: "Рекомендуем", - facts: [ - { ok: true, text: "Назначения врача подгружаются автоматически" }, - { ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" }, - { ok: true, text: "История доступна с любого устройства" }, - ], - action: "Войти через ЕМИАС", + text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.", }, anonymous: { title: "Анонимно", - facts: [ - { ok: true, text: "Не нужны имя, полис и телефон" }, - { ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" }, - { ok: false, text: "Врач не видит ваши ответы и не получает сигналов" }, - { ok: false, text: "История доступна только на этом устройстве" }, - ], - action: "Продолжить анонимно", + text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.", }, disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.", }, emias: { - back: "Способы входа", - title: "Вход через ЕМИАС", - demoNotice: - "Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.", - codeLabel: "Код подключения от врача", - codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.", + title: "Введите код от врача", + lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.", + demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.", + codeLabel: "Код подключения", codePlaceholder: "Код из памятки", - submit: "Войти", + submit: "Продолжить", submitting: "Проверяем код…", - codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.", - networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.", + codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.", or: "или", demoTitle: "Демо-пациенты", - demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.", + demoLead: "Нет кода? Войдите под синтетической учётной записью.", demoDay: (day: number) => `${day}-й день терапии`, - demoAction: "Войти", - demoEmpty: "Демо-пациенты недоступны.", }, anonymous: { - back: "Способы входа", title: "Анонимный вход", - lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.", - points: [ - "Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.", - "Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.", - "Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.", - ], - warning: - "История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.", - submit: "Начать анонимно", - submitting: "Создаём анонимный профиль…", + lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.", + yesTitle: "Что будет доступно:", + yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"], + noTitle: "Чего не будет:", + no: ["Врач не увидит ответы", "История только на этом устройстве"], + warning: "После выхода историю восстановить нельзя.", + submit: "Продолжить анонимно", + submitting: "Создаём профиль…", }, - chat: { - modeEmias: "ЕМИАС", - modeAnonymous: "Анонимно", + today: { + title: "Сегодня", + takeMed: "Принять лекарство", + now: "Сейчас", + checkin: "Отметка самочувствия", + checkinHint: "меньше минуты", + done: "Уже выполнено", + medTaken: "Лекарство", + medTakenAt: (time: string) => `принято, ${time}`, + checkinDone: "Самочувствие отмечено", + allDoneTitle: "На сегодня всё сделано", + allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`, + allDoneNoMeds: "Следующая отметка самочувствия — завтра", + viewMeds: "Посмотреть лекарства", + visit: "Следующий визит к врачу", + medQuestion: "Вопрос о лекарстве", + messageDoctor: "Сообщение врачу", + noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.", + newMessage: "Новое сообщение", + open: "Открыть", + }, + meds: { + title: "Моя схема приёма", + lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.", + byDoctor: "По назначению врача", + tablet: "1 приём", + week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"], + onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.", + empty: "Назначения появятся здесь после входа через ЕМИАС.", + question: "Вопрос о лекарстве", + reminderTitle: "Время принять препарат", + reminderLead: "По назначению врача", + taken: "Принял(а)", + skip: "Пропущу", + sideEffects: "Есть побочные эффекты", + }, + doctor: { + title: "Врач", + visit: "Следующий визит", + visitNone: "Дата следующего визита пока не назначена", + attending: "Ваш лечащий врач", + write: "Написать врачу", + writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.", + earlier: "Записаться к врачу раньше", + plan: "Мой план безопасности", + noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.", + }, + profile: { + title: "Профиль", + signInMode: "Способ входа", + emias: "ЕМИАС", + anonymous: "Анонимно", + connectEmias: "Подключить ЕМИАС", + data: "Мои данные", + revoke: "Отозвать согласие", + revokeTitle: "Отозвать согласие?", + revokeText: "Ваши ответы больше не будут обрабатываться в приложении.", + revokeConfirm: "Да, отозвать", + cancel: "Отмена", + wipe: "Удалить данные с устройства", + wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.", logout: "Выйти", - logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.", + messages: "Сообщения", + }, + flow: { + question: (n: number, total: number) => `Вопрос ${n} из ${total}`, + period: "За последний месяц", + textPlaceholder: "Напишите несколько слов", + sending: "Отправляем…", + doneTitle: "Готово", + doneLead: "Врач увидит отметку.", + breathe: "Вдох на 4 — выдох на 6", + breatheIn: "Вдох", + breatheOut: "Выдох", + openPlan: "Открыть мой план безопасности", + supportTitle: "Вы не одни", + exit: "К главной", + planTitle: "Мой план безопасности", + consentTitle: "Согласие на обработку данных о здоровье", + }, + messages: { + title: "Сообщения", inputLabel: "Сообщение", inputPlaceholder: "Напишите сообщение", - send: "Отправить", - loading: "Подключаемся…", - sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.", you: "Вы", - assistant: "Помощник", - crisisLabel: "Важно", + empty: "Здесь будут сообщения помощника.", }, footer: { law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).", diff --git a/web/src/dialog/DialogContext.tsx b/web/src/dialog/DialogContext.tsx new file mode 100644 index 0000000..13d4793 --- /dev/null +++ b/web/src/dialog/DialogContext.tsx @@ -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; + start: () => Promise; + markRead: () => void; +} + +const Ctx = createContext(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(() => (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( + () => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }), + [snap, busy, error, send, start, markRead], + ); + return {children}; +} + +export function useDialog(): DialogApi { + const value = useContext(Ctx); + if (!value) throw new Error("useDialog outside DialogProvider"); + return value; +} diff --git a/web/src/dialog/step.ts b/web/src/dialog/step.ts new file mode 100644 index 0000000..8afbff3 --- /dev/null +++ b/web/src/dialog/step.ts @@ -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; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index a1563b2..20f895f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,28 +1,47 @@ import "@fontsource/golos-text/400.css"; import "@fontsource/golos-text/500.css"; import "@fontsource/golos-text/600.css"; +import "@fontsource/golos-text/700.css"; import "./theme/tokens.css"; import "./theme/global.css"; -import { StrictMode } from "react"; +import { StrictMode, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import { HelpProvider } from "./components/HelpSheet"; +import { DialogProvider } from "./dialog/DialogContext"; import { AnonymousSignIn } from "./screens/AnonymousSignIn"; -import { Chat } from "./screens/Chat"; +import { Doctor } from "./screens/Doctor"; import { EmiasSignIn } from "./screens/EmiasSignIn"; +import { Flow } from "./screens/Flow"; +import { DoseReminder, Meds } from "./screens/Meds"; +import { Messages } from "./screens/Messages"; +import { Profile } from "./screens/Profile"; +import { Today } from "./screens/Today"; import { Welcome } from "./screens/Welcome"; import { SessionProvider, useSession } from "./session/SessionContext"; +function Private({ children }: { children: ReactNode }) { + const { session } = useSession(); + return session ? <>{children} : ; +} + // Hash routing works the same on a static host, file:// and inside the Capacitor WebView. function AppRoutes() { const { session } = useSession(); return ( - : } /> + : } /> } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> ); @@ -31,9 +50,13 @@ function AppRoutes() { createRoot(document.getElementById("root")!).render( - - - + + + + + + + , ); diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx index 364ce8b..ccfe4a5 100644 --- a/web/src/screens/AnonymousSignIn.tsx +++ b/web/src/screens/AnonymousSignIn.tsx @@ -2,11 +2,15 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "../api/client"; -import { AppShell, BackLink, Notice } from "../components/AppShell"; -import { Icon } from "../components/Icon"; +import { AppShell, Notice, TopBar } from "../components/AppShell"; +import { Icon, type IconName } from "../components/Icon"; import { ru } from "../content/ru"; import { useSession } from "../session/SessionContext"; +const YES_ICONS: IconName[] = ["calendar", "pill", "doc"]; +const NO_ICONS: IconName[] = ["user", "doc"]; + +// A3 · Анонимный вход export function AnonymousSignIn() { const t = ru.anonymous; const navigate = useNavigate(); @@ -19,7 +23,7 @@ export function AnonymousSignIn() { setError(false); try { signIn(await api.signInAnonymous()); - navigate("/chat", { replace: true }); + navigate("/flow", { replace: true }); } catch { setError(true); setBusy(false); @@ -27,35 +31,55 @@ export function AnonymousSignIn() { }; return ( - -
-
- {t.back} -
-

- {t.title} -

-

{t.lead}

-
    - {t.points.map((p) => ( -
  • - - {p} -
  • - ))} -
- {t.warning} - {error && ( -

- {ru.emias.networkError} -

- )} - -
-
-
+ +
+ navigate("/")} /> +
+

{t.title}

+

{t.lead}

+
+ +
+

+ + + + {t.yesTitle} +

+
    + {t.yes.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ +
+

+ + + + {t.noTitle} +

+
    + {t.no.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ + {t.warning} + {error && {ru.common.networkError}} + + +
); } diff --git a/web/src/screens/Chat.tsx b/web/src/screens/Chat.tsx deleted file mode 100644 index 041c986..0000000 --- a/web/src/screens/Chat.tsx +++ /dev/null @@ -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) => ( - - {i > 0 &&
} - {line.split(PHONE_RE).map((part, j) => - j % 2 === 1 ? ( - - {part} - - ) : ( - {part} - ), - )} -
- ))} - - ); -} - -function AssistantMessage({ - entry, - disabled, - onPress, -}: { - entry: Extract; - disabled: boolean; - onPress: (button: DialogButton) => void; -}) { - const { reply } = entry; - const special = reply.kind === "crisis" || reply.kind === "safety_plan"; - return ( -
  • -
    - {special && ( -

    - - {ru.chat.crisisLabel} -

    - )} -

    - -

    -
    - {reply.buttons.length > 0 && !entry.answered && ( -
    - {reply.buttons.map((row, r) => ( -
    - {row.map((b) => ( - - ))} -
    - ))} -
    - )} -
  • - ); -} - -export function Chat() { - const t = ru.chat; - const navigate = useNavigate(); - const { session, signOut } = useSession(); - const token = session?.token ?? ""; - const [entries, setEntries] = useState(() => (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(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(); - 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) => { - 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 ( - -
    -
    - - - {session.mode === "emias" ? t.modeEmias : t.modeAnonymous} - {session.displayName && · {session.displayName}} - - {confirmLogout ? ( - - {t.logoutConfirm} - - - - ) : ( - - )} -
    -
    - -
    -
      - {entries.map((entry) => - entry.from === "assistant" ? ( - - ) : ( -
    1. - {t.you}: -
      -

      {entry.text}

      -
      -
    2. - ), - )} - {busy && ( -
    3. -
      - -
      -
    4. - )} -
    -
    -
    - -
    - {error && ( -

    - {t.sendError} -

    - )} -
    - -