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>
);
}