feat(web): patient web app with ЕМИАС (demo) and anonymous sign-in
A second channel to the same dialog engine as the Telegram bot: consent, onboarding, scales and check-ins come from app/dialog; the bot is unchanged. - API: /auth/emias (doctor's one-time connect code via EmiasMock), /auth/emias/demo (synthetic personas), /auth/anonymous (random token, nothing stored in the identity DB), /crisis/resources from crisis.yaml - web/: React + TS + Vite, mobile-first, safe areas, hash routing, tokens and texts isolated for a Capacitor mobile build Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { ru } from "../content/ru";
|
||||
import { HelpSheet } from "./HelpSheet";
|
||||
import { Icon, LogoMark } from "./Icon";
|
||||
|
||||
export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<HelpSheet open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</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 }) {
|
||||
return (
|
||||
<div className={`notice notice--${tone}`} role="note">
|
||||
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } 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 }];
|
||||
|
||||
const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
|
||||
|
||||
export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.crisisResources()
|
||||
.then((r) => r.length && setResources(r))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="sheet"
|
||||
aria-labelledby="help-title"
|
||||
onClose={onClose}
|
||||
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>
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="sheet__lead">{ru.help.lead}</p>
|
||||
<ul 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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Minimal line icon set (24px grid, 1.75 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" />
|
||||
),
|
||||
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" />,
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5m0-8.5v.5" />
|
||||
</>
|
||||
),
|
||||
alert: (
|
||||
<>
|
||||
<path d="M12 3.5l9.5 16.5h-19L12 3.5z" />
|
||||
<path d="M12 10v4.5m0 2.5v.5" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
|
||||
<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" />,
|
||||
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" />,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof paths;
|
||||
|
||||
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
focusable="false"
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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)" />
|
||||
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user