feat(web): full desktop layout instead of a stretched phone view

- focus screens: question and context on the left, answers on the right in
  one wide card; consent text and the 0–10 scale in one wide column
- today: dashboard with tasks, this week's doses and quick actions side by
  side; meds / doctor / profile in two-column grids; welcome gets «Как это
  работает» steps
- fix: 0–10 scale no longer overflows narrow containers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:25:17 +03:00
co-authored by Claude Opus 5.5
parent f233c1e5ca
commit 40c242bb1a
10 changed files with 558 additions and 192 deletions
+11 -3
View File
@@ -32,13 +32,20 @@ export function AnonymousSignIn() {
return (
<AppShell signedIn={false} focus>
<div className="stack narrow">
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="split">
<div className="split__lead">
<header className="head">
<span className="tile-icon">
<Icon name="lock" size={26} />
</span>
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<Notice tone="warn">{t.warning}</Notice>
</div>
<div className="split__act">
<section className="panel panel--teal">
<p className="panel__title">
<span className="badge-icon badge-icon--teal">
@@ -73,12 +80,13 @@ export function AnonymousSignIn() {
</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>
</div>
</div>
</AppShell>
);
+2 -2
View File
@@ -32,7 +32,7 @@ export function Doctor() {
{plan && !plan.has_doctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<>
<div className="cols">
<section className="card-list" aria-label={t.visit}>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
@@ -65,7 +65,7 @@ export function Doctor() {
))}
</div>
<Notice>{t.writeNote}</Notice>
</>
</div>
)}
</div>
</AppShell>
+11 -4
View File
@@ -59,13 +59,20 @@ export function EmiasSignIn() {
return (
<AppShell signedIn={false} focus>
<div className="stack narrow">
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="split">
<div className="split__lead">
<header className="head">
<span className="tile-icon tile-icon--teal">
<Icon name="docPlus" size={26} />
</span>
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<Notice>{t.demoNotice}</Notice>
</div>
<div className="split__act">
<form className="stack stack--sm" onSubmit={submit} noValidate>
<label htmlFor="code" className="visually-hidden">
{t.codeLabel}
@@ -93,8 +100,6 @@ export function EmiasSignIn() {
</button>
</form>
<Notice>{t.demoNotice}</Notice>
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
@@ -115,6 +120,8 @@ export function EmiasSignIn() {
</button>
))}
</section>
</div>
</div>
</div>
</AppShell>
);
+53 -59
View File
@@ -322,70 +322,59 @@ export function Flow() {
const top = (
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
);
let body: ReactNode;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode;
let act: ReactNode;
switch (step.kind) {
case "consent":
body = (
lead = (
<>
<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>
</>
);
act = (
<div className="btn-row btn-row--stack-desk">
{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} />
</>
);
lead = <Head title={step.title} lead={step.lead} icon="smile" />;
act = <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} />
</>
lead = (
<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>
);
act = <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}
/>
</>
lead = <Head title={step.title} lead={step.lead} icon="edit" />;
act = (
<FreeText key={stepKey} skip={step.skip} busy={dialog.busy} onSend={say} onSkip={press} onExit={home} />
);
break;
case "crisis":
body = (
lead = (
<>
<header className="head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
@@ -393,15 +382,19 @@ export function Flow() {
</span>
<h1 className="h1">{ru.flow.supportTitle}</h1>
</header>
<div className="panel">
<Body texts={[step.text]} />
</div>
</>
);
act = (
<>
<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}
@@ -423,9 +416,9 @@ export function Flow() {
);
break;
case "plan":
body = (
lead = <Head title={step.title || ru.flow.planTitle} lead={step.lead} icon="shield" />;
act = (
<>
<Head title={step.title || ru.flow.planTitle} lead={step.lead} />
<ul className="plan">
{step.lines.map((l) => (
<li key={l} className="plan__item">
@@ -441,19 +434,20 @@ export function Flow() {
);
break;
case "choice":
body = (
<>
<Head title={step.title} lead={step.lead} />
<Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />
</>
);
lead = <Head title={step.title} lead={step.lead} />;
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
content = (
<div className="stack narrow" key={stepKey}>
<div className="focus" key={stepKey}>
{top}
{body}
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<div className={`split split--${step.kind}`}>
<div className="split__lead">{lead}</div>
<div className="split__act">
{act}
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</div>
</div>
);
}
+27 -11
View File
@@ -1,5 +1,7 @@
import { useNavigate, useParams } from "react-router-dom";
import type { TodayPrescription } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
@@ -13,6 +15,21 @@ const DOT: Record<string, { cls: string; icon?: IconName }> = {
none: { cls: "wk" },
};
export function WeekDots({ week }: { week: TodayPrescription["week"] }) {
return (
<ol className="week" aria-label="Отметки за неделю">
{week.map((w, i) => (
<li key={w.day} className="week__day">
<span className="week__label">{ru.meds.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>
);
}
// E4 · Моя схема приёма
export function Meds() {
const t = ru.meds;
@@ -33,6 +50,7 @@ export function Meds() {
{plan && plan.prescriptions.length === 0 && <Notice>{t.empty}</Notice>}
<div className="grid2">
{plan?.prescriptions.map((p) => (
<article key={p.drug} className="rx">
<div className="rx__head">
@@ -55,18 +73,10 @@ export function Meds() {
</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>
<WeekDots week={p.week} />
</article>
))}
</div>
{plan && plan.prescriptions.length > 0 && <Notice>{t.onlyDoctor}</Notice>}
@@ -95,8 +105,10 @@ export function DoseReminder() {
return (
<AppShell focus>
<div className="stack narrow">
<div className="focus">
<TopBar onBack={() => navigate("/today")} />
<div className="split">
<div className="split__lead">
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
@@ -115,6 +127,8 @@ export function DoseReminder() {
</p>
)}
</div>
</div>
<div className="split__act">
<div className="acts">
<button type="button" className="act act--teal" disabled={dialog.busy} onClick={() => answer("med:taken", t.taken)}>
<span className="act__icon">
@@ -141,6 +155,8 @@ export function DoseReminder() {
</button>
</div>
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</div>
</div>
</AppShell>
);
+2
View File
@@ -38,6 +38,7 @@ export function Profile() {
<h1 className="h1">{t.title}</h1>
</header>
<div className="cols">
<section className="stack stack--sm">
<div className="row-card row-card--static">
<span className="avatar avatar--lg">
@@ -107,6 +108,7 @@ export function Profile() {
<Icon name="chevronRight" />
</button>
</section>
</div>
</div>
<dialog ref={ref} className="modal" aria-labelledby="revoke-title" onClose={() => setConfirm(false)}>
+165 -103
View File
@@ -1,12 +1,13 @@
import { useNavigate } from "react-router-dom";
import type { TodayDose } from "../api/types";
import type { TodayDose, TodayPrescription } 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 { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday";
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
@@ -24,6 +25,26 @@ function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
);
}
function WeekCard({ rx, onOpen }: { rx: TodayPrescription; onOpen: () => void }) {
const taken = rx.week.filter((w) => w.status === "taken").length;
const counted = rx.week.filter((w) => w.status !== "none").length;
return (
<button type="button" className="card weekcard" onClick={onOpen}>
<span className="card__head">
<span className="tile-icon tile-icon--teal">
<Icon name="pill" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.week}</span>
<span className="row-card__title">{ru.today.weekTaken(taken, counted)}</span>
</span>
<Icon name="chevronRight" />
</span>
<WeekDots week={rx.week} />
</button>
);
}
// C1 / C2 · Сегодня
export function Today() {
const t = ru.today;
@@ -43,20 +64,27 @@ export function Today() {
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 noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor);
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;
const hasDone = takenDoses.length > 0 || plan?.checkin_done;
return (
<AppShell aside={visit}>
<div className="stack">
<header className="today-head">
<div className="dash">
<header className="today-head dash__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}>
<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>
@@ -64,113 +92,147 @@ export function Today() {
{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>
)}
<section className="dash__main" aria-label={t.title}>
{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, 90)}</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) => (
{allDone ? (
<div 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 btn--auto" onClick={() => navigate("/meds")}>
{t.viewMeds}
<Icon name="chevronRight" size={20} />
</button>
)}
</div>
) : (
<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} · {d.drug}, {d.dose}
</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>
)}
</section>
<section className="dash__side">
{plan?.next_visit && (
<div className="only-phone">
<VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} />
</div>
)}
{!allDone && hasDone && (
<div className="card">
<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>
)}
</div>
)}
{plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />}
{noDoctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<div className="quick">
<button
key={d.id}
type="button"
className={d.time <= now && !hasOpenStep ? "task task--hero" : "task"}
onClick={() => openDose(d)}
className="quick__btn"
onClick={() => go("menu:med_question", t.medQuestion)}
disabled={dialog.busy}
>
<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 className="tile-icon tile-icon--blue">
<Icon name="question" />
</span>
<span>
<span className="done-row__title">{t.medTaken}</span>
<span className="done-row__sub">{t.medTakenAt(d.time)}</span>
<span className="quick__title">{t.medQuestion}</span>
<span className="quick__sub">{t.medQuestionHint}</span>
</span>
</div>
))}
{plan?.checkin_done && (
<div className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</button>
<button
type="button"
className="quick__btn"
onClick={() => go("menu:contact_doctor", t.messageDoctor)}
disabled={dialog.busy}
>
<span className="tile-icon tile-icon--blue">
<Icon name="message" />
</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>
)}
<span>
<span className="quick__title">{t.messageDoctor}</span>
<span className="quick__sub">{t.messageDoctorHint}</span>
</span>
</button>
</div>
)}
</section>
</div>
</AppShell>
);
+17
View File
@@ -46,6 +46,23 @@ export function Welcome() {
</button>
</div>
<section className="how" aria-labelledby="how-title">
<h2 id="how-title" className="h3">
{t.howTitle}
</h2>
<ol className="how__list">
{t.how.map((step, i) => (
<li key={step.title} className="how__item">
<span className="how__num">{i + 1}</span>
<span>
<span className="how__title">{step.title}</span>
<span className="how__text">{step.text}</span>
</span>
</li>
))}
</ol>
</section>
<p className="muted small">{t.disclaimer}</p>
</div>
</AppShell>