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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:11:45 +03:00
co-authored by Claude Opus 5.5
parent b8a93ce9d4
commit b4b1bc51c8
28 changed files with 3795 additions and 1770 deletions
+159 -47
View File
@@ -1,64 +1,176 @@
import { useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import type { ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { ru } from "../content/ru";
import { HelpSheet } from "./HelpSheet";
import { Icon, LogoMark } from "./Icon";
import { useDialog } from "../dialog/DialogContext";
import { HotlineCard, useHelp } from "./HelpSheet";
import { Icon, LogoMark, type IconName } from "./Icon";
export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
const [helpOpen, setHelpOpen] = useState(false);
/*
* Layout from docs/mock (series 2).
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel)
*/
const TABS: { to: string; label: string; icon: IconName }[] = [
{ to: "/today", label: ru.nav.today, icon: "home" },
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
{ to: "/profile", label: ru.nav.profile, icon: "user" },
];
/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */
export function EmergencyBlock() {
const { resources, open } = useHelp();
return (
<div className="shell">
<header className="topbar">
<div className="topbar__inner">
<Link to="/" className="brand" aria-label={ru.product.name}>
<LogoMark />
<span className="brand__text">
<span className="brand__name">{ru.product.name}</span>
<span className="brand__tagline">{ru.product.tagline}</span>
</span>
</Link>
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}>
<Icon name="phone" size={18} />
<span>{ru.help.button}</span>
</button>
</div>
</header>
<section className="sosblock" aria-label={ru.help.block}>
<button type="button" className="sosblock__head" onClick={open}>
<span className="sosblock__icon">
<Icon name="phone" size={18} />
</span>
<span>{ru.help.block}</span>
<Icon name="chevronRight" size={20} />
</button>
<div className="sosblock__lines">
{resources.slice(0, 2).map((r) => (
<HotlineCard key={r.phone} r={r} size="sm" />
))}
</div>
</section>
);
}
{children}
{footer && (
<footer className="footer">
<div className="footer__inner">
<p className="footer__line">
<Icon name="lock" size={16} />
<span>{ru.footer.law}</span>
</p>
<p className="footer__line footer__line--muted">{ru.footer.prototype}</p>
</div>
</footer>
function Sidebar({ signedIn }: { signedIn: boolean }) {
const { open } = useHelp();
const { unread } = useDialog();
return (
<nav className="sidebar" aria-label="Разделы">
<Link to={signedIn ? "/today" : "/"} className="sidebar__brand">
<LogoMark />
<span>
<span className="sidebar__name">{ru.product.name}</span>
<span className="sidebar__tag">{ru.product.tagline}</span>
</span>
</Link>
{signedIn ? (
<ul className="sidebar__list">
{TABS.map((t) => (
<li key={t.to}>
<NavLink to={t.to} className="sidebar__link">
<Icon name={t.icon} />
{t.label}
</NavLink>
</li>
))}
<li>
<NavLink to="/messages" className="sidebar__link">
<Icon name="message" />
{ru.nav.messages}
{unread > 0 && <span className="dot" aria-label="новое" />}
</NavLink>
</li>
</ul>
) : (
<ul className="sidebar__list">
<li>
<NavLink to="/" end className="sidebar__link">
<Icon name="home" />
{ru.nav.signIn}
</NavLink>
</li>
<li>
<button type="button" className="sidebar__link" onClick={open}>
<Icon name="question" />
{ru.nav.help}
</button>
</li>
</ul>
)}
</nav>
);
}
<HelpSheet open={helpOpen} onClose={() => setHelpOpen(false)} />
function BottomNav() {
return (
<nav className="tabbar" aria-label="Разделы">
{TABS.map((t) => (
<NavLink key={t.to} to={t.to} className="tabbar__item">
<Icon name={t.icon} />
<span>{t.label}</span>
</NavLink>
))}
</nav>
);
}
function Rail({ aside }: { aside?: ReactNode }) {
const { resources, open } = useHelp();
return (
<aside className="rail">
{aside}
<section className="railsos" aria-label={ru.help.block}>
<button type="button" className="railsos__head" onClick={open}>
<Icon name="phone" size={18} />
<span>{ru.help.block}</span>
</button>
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} size="sm" />
))}
</section>
</aside>
);
}
export function AppShell({
children,
signedIn = true,
aside,
focus = false,
sos = true,
}: {
children: ReactNode;
signedIn?: boolean;
aside?: ReactNode;
/** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */
focus?: boolean;
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
sos?: boolean;
}) {
return (
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} />
<div className="app__main">
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
<Rail aside={aside} />
</div>
{signedIn && !focus && <BottomNav />}
</div>
);
}
export function BackLink({ to, children }: { to: string; children: ReactNode }) {
return (
<Link to={to} className="back-link">
<Icon name="arrowLeft" size={18} />
<span>{children}</span>
</Link>
);
}
export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
return (
<div className={`notice notice--${tone}`} role="note">
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
<p>{children}</p>
<Icon name={icon} />
<div>{children}</div>
</div>
);
}
export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) {
return (
<div className="topbar">
{onBack && (
<button type="button" className="icon-btn" onClick={onBack} aria-label={ru.common.back}>
<Icon name="arrowLeft" />
</button>
)}
{title && <span className="topbar__title">{title}</span>}
{children}
</div>
);
}
+72 -26
View File
@@ -1,18 +1,27 @@
import { useEffect, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { api } from "../api/client";
import type { CrisisResource } from "../api/types";
import { ru } from "../content/ru";
import { Icon } from "./Icon";
// 112 is shown even when the API is unreachable: the help sheet must never be empty.
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }];
// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»).
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }];
const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
interface HelpApi {
resources: CrisisResource[];
open: () => void;
}
const Ctx = createContext<HelpApi>({ resources: FALLBACK, open: () => undefined });
export const useHelp = () => useContext(Ctx);
/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */
export function HelpProvider({ children }: { children: ReactNode }) {
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
const [isOpen, setOpen] = useState(false);
useEffect(() => {
api
@@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
.catch(() => undefined);
}, []);
const open = useCallback(() => setOpen(true), []);
return (
<Ctx.Provider value={{ resources, open }}>
{children}
<HelpSheet open={isOpen} onClose={() => setOpen(false)} resources={resources} />
</Ctx.Provider>
);
}
export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) {
const emergency = r.phone.replace(/\D/g, "").length <= 3;
return (
<a className={`hotline hotline--${size}`} href={telHref(r.phone)}>
<span className="hotline__icon">
<Icon name={emergency ? "phone" : "headset"} size={size === "lg" ? 26 : 20} />
</span>
<span className="hotline__text">
{emergency ? (
<>
<span className="hotline__phone">{r.phone}</span>
<span className="hotline__name">{r.name}</span>
</>
) : (
<>
<span className="hotline__name hotline__name--strong">{r.name}</span>
<span className="hotline__phone hotline__phone--sm">{r.phone}</span>
</>
)}
{r.note && size === "lg" && <span className="hotline__note">{r.note}</span>}
</span>
{size === "lg" && <Icon name="chevronRight" size={20} />}
</a>
);
}
function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
@@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi
onClick={(e) => e.target === ref.current && onClose()}
>
<div className="sheet__body">
<div className="sheet__head">
<h2 id="help-title" className="sheet__title">
{ru.help.title}
</h2>
<div className="sheet__bar">
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
<Icon name="close" />
</button>
<h2 id="help-title" className="sheet__title">
{ru.help.title}
</h2>
</div>
<p className="sheet__lead">{ru.help.lead}</p>
<ul className="hotlines">
<div className="sos">
<span className="sos__ring">
<span className="sos__btn">
<Icon name="phone" size={40} />
</span>
</span>
<p className="sos__lead">{ru.help.lead}</p>
</div>
<div className="hotlines">
{resources.map((r) => (
<li key={r.phone}>
<a className="hotline" href={telHref(r.phone)}>
<span className="hotline__icon">
<Icon name="phone" size={22} />
</span>
<span className="hotline__text">
<span className="hotline__phone">{r.phone}</span>
<span className="hotline__name">{r.name}</span>
{r.note && <span className="hotline__note">{r.note}</span>}
</span>
<span className="hotline__call">{ru.help.call}</span>
</a>
</li>
<HotlineCard key={r.phone} r={r} />
))}
</ul>
</div>
<p className="notice notice--info">
<Icon name="info" />
<span>{ru.help.alwaysNote}</span>
</p>
</div>
</dialog>
);
+104 -26
View File
@@ -1,14 +1,37 @@
// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers.
// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers.
const paths = {
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
minus: <path d="M6 12h12" />,
phone: (
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
home: <path d="M4 10.5L12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-6h-5v6H5a1 1 0 0 1-1-1v-8.5z" />,
pill: (
<>
<rect x="3" y="8.5" width="18" height="7" rx="3.5" transform="rotate(-45 12 12)" />
<path d="M9.5 9.5l5 5" />
</>
),
doctor: (
<>
<circle cx="12" cy="7.5" r="3.5" />
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
<path d="M12 15.5v3m-1.5-1.5h3" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="4" />
<path d="M4.5 20.5c.8-3.7 3.8-6 7.5-6s6.7 2.3 7.5 6" />
</>
),
bell: (
<>
<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 2h-15L6 16.5z" />
<path d="M10 20.5a2 2 0 0 0 4 0" />
</>
),
chevronRight: <path d="M9.5 6l6 6-6 6" />,
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />,
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
x: <path d="M7 7l10 10M17 7L7 17" />,
info: (
<>
<circle cx="12" cy="12" r="9" />
@@ -21,33 +44,88 @@ const paths = {
<path d="M12 10v4.5m0 2.5v.5" />
</>
),
phone: (
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
),
headset: (
<>
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
<rect x="3" y="13.5" width="4" height="6" rx="1.5" />
<rect x="17" y="13.5" width="4" height="6" rx="1.5" />
</>
),
calendar: (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
<path d="M3.5 9.5h17M8 3v4m8-4v4M8 13h1m3 0h1m3 0h1M8 16.5h1m3 0h1" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</>
),
pin: (
<>
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
<circle cx="12" cy="10" r="2.5" />
</>
),
smile: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M8.5 14c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2M9 9.8v.4m6-.4v.4" />
</>
),
doc: (
<>
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
<path d="M14 3.5V8h4M9 12h6m-6 3.5h6" />
</>
),
docPlus: (
<>
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
<path d="M14 3.5V8h4M12 11v6m-3-3h6" />
</>
),
shield: (
<>
<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6L12 3z" />
<path d="M9 12l2 2 4-4" />
</>
),
lock: (
<>
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
<rect x="5" y="10.5" width="14" height="10" rx="2" />
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
</>
),
pill: (
<>
<rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-45 12 12)" />
<path d="M9.5 9.5l5 5" />
</>
),
pulse: <path d="M3 12h4l2-5 4 10 2-5h6" />,
doctor: (
<>
<circle cx="12" cy="7.5" r="3.5" />
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
<path d="M12 15.5v3m-1.5-1.5h3" />
</>
),
close: <path d="M6 6l12 12M18 6L6 18" />,
edit: <path d="M5 19l1-4L15.5 5.5a2 2 0 0 1 3 3L9 18l-4 1zM14 7l3 3" />,
trash: <path d="M5 7h14M10 7V4.5h4V7m-7 0l1 12.5h8L17 7M10.5 11v5m3-5v5" />,
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
heart: <path d="M12 19.5s-7.5-4.4-7.5-10a4.2 4.2 0 0 1 7.5-2.6 4.2 4.2 0 0 1 7.5 2.6c0 5.6-7.5 10-7.5 10z" />,
lungs: (
<>
<path d="M12 4v8m0 0c-1 1.2-2.3 2-3.5 2M12 12c1 1.2 2.3 2 3.5 2" />
<path d="M8.5 7.5C6 8 4 11.5 4 16c0 2 1 3.5 3 3.5s3-1.5 3-4V9M15.5 7.5C18 8 20 11.5 20 16c0 2-1 3.5-3 3.5s-3-1.5-3-4V9" />
</>
),
question: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M9.6 9.5a2.4 2.4 0 0 1 4.7.7c0 1.6-2.3 2-2.3 3.6m0 2.7v.3" />
</>
),
message: <path d="M4 5.5h16v11H9.5L5 20v-3.5H4z" />,
plus: <path d="M12 5v14M5 12h14" />,
} as const;
export type IconName = keyof typeof paths;
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) {
return (
<svg
width={size}
@@ -55,7 +133,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
role={label ? "img" : undefined}
@@ -71,7 +149,7 @@ export function Icon({ name, size = 20, label }: { name: IconName; size?: number
export function LogoMark({ size = 32 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect width="32" height="32" rx="6" fill="var(--c-primary)" />
<rect width="32" height="32" rx="9" fill="var(--c-teal)" />
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
</svg>
);