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,61 @@
|
||||
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 { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
export function AnonymousSignIn() {
|
||||
const t = ru.anonymous;
|
||||
const navigate = useNavigate();
|
||||
const { signIn } = useSession();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError(false);
|
||||
try {
|
||||
signIn(await api.signInAnonymous());
|
||||
navigate("/chat", { replace: true });
|
||||
} catch {
|
||||
setError(true);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="page" id="main">
|
||||
<div className="narrow">
|
||||
<BackLink to="/">{t.back}</BackLink>
|
||||
<section className="panel" aria-labelledby="anon-title">
|
||||
<h1 id="anon-title" className="h2">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="lead lead--sm">{t.lead}</p>
|
||||
<ul className="facts facts--spaced">
|
||||
{t.points.map((p) => (
|
||||
<li key={p} className="fact">
|
||||
<Icon name="check" size={18} />
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Notice tone="warning">{t.warning}</Notice>
|
||||
{error && (
|
||||
<p className="field__error" role="alert">
|
||||
{ru.emias.networkError}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line.split(PHONE_RE).map((part, j) =>
|
||||
j % 2 === 1 ? (
|
||||
<a key={j} href={`tel:${part.replace(/[^\d+]/g, "")}`} className="tel">
|
||||
{part}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={j}>{part}</Fragment>
|
||||
),
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
entry,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
entry: Extract<ChatEntry, { from: "assistant" }>;
|
||||
disabled: boolean;
|
||||
onPress: (button: DialogButton) => void;
|
||||
}) {
|
||||
const { reply } = entry;
|
||||
const special = reply.kind === "crisis" || reply.kind === "safety_plan";
|
||||
return (
|
||||
<li className={`msg msg--assistant msg--${reply.kind}`}>
|
||||
<div className="msg__bubble">
|
||||
{special && (
|
||||
<p className="msg__label">
|
||||
<Icon name="alert" size={16} />
|
||||
{ru.chat.crisisLabel}
|
||||
</p>
|
||||
)}
|
||||
<p className="msg__text">
|
||||
<RichText text={reply.text} />
|
||||
</p>
|
||||
</div>
|
||||
{reply.buttons.length > 0 && !entry.answered && (
|
||||
<div className="choices">
|
||||
{reply.buttons.map((row, r) => (
|
||||
<div key={r} className="choices__row">
|
||||
{row.map((b) => (
|
||||
<button
|
||||
key={b.data}
|
||||
type="button"
|
||||
className="choice"
|
||||
disabled={disabled}
|
||||
onClick={() => onPress(b)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const t = ru.chat;
|
||||
const navigate = useNavigate();
|
||||
const { session, signOut } = useSession();
|
||||
const token = session?.token ?? "";
|
||||
const [entries, setEntries] = useState<ChatEntry[]>(() => (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<HTMLDivElement>(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<string>();
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<AppShell footer={false}>
|
||||
<div className="chat-bar">
|
||||
<div className="chat-bar__inner">
|
||||
<span className={`mode mode--${session.mode}`}>
|
||||
<Icon name={session.mode === "emias" ? "doctor" : "lock"} size={16} />
|
||||
{session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
|
||||
{session.displayName && <span className="mode__name">· {session.displayName}</span>}
|
||||
</span>
|
||||
{confirmLogout ? (
|
||||
<span className="logout-confirm" role="group" aria-label={t.logout}>
|
||||
<span className="logout-confirm__text">{t.logoutConfirm}</span>
|
||||
<button type="button" className="btn btn--danger btn--sm" onClick={signOut}>
|
||||
{t.logout}
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label={ru.help.close} onClick={() => setConfirmLogout(false)}>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
|
||||
>
|
||||
<Icon name="logout" size={18} />
|
||||
{t.logout}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="chat" id="main">
|
||||
<ol className="messages" aria-live="polite" aria-label={t.assistant}>
|
||||
{entries.map((entry) =>
|
||||
entry.from === "assistant" ? (
|
||||
<AssistantMessage key={entry.id} entry={entry} disabled={busy} onPress={press} />
|
||||
) : (
|
||||
<li key={entry.id} className="msg msg--patient">
|
||||
<span className="visually-hidden">{t.you}: </span>
|
||||
<div className="msg__bubble">
|
||||
<p className="msg__text">{entry.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{busy && (
|
||||
<li className="msg msg--assistant msg--typing" aria-label={t.loading}>
|
||||
<div className="msg__bubble">
|
||||
<span className="dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
<div ref={endRef} />
|
||||
</main>
|
||||
|
||||
<form className="composer" onSubmit={sendText}>
|
||||
{error && (
|
||||
<p className="composer__error" role="alert">
|
||||
{t.sendError}
|
||||
</p>
|
||||
)}
|
||||
<div className="composer__row">
|
||||
<label htmlFor="msg" className="visually-hidden">
|
||||
{t.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
className="composer__input"
|
||||
rows={1}
|
||||
value={draft}
|
||||
placeholder={t.inputPlaceholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={busy || !draft.trim()} aria-label={t.send}>
|
||||
<Icon name="send" size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError, api } from "../api/client";
|
||||
import type { AuthResult, DemoPersona } from "../api/types";
|
||||
import { AppShell, BackLink, Notice } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
export function EmiasSignIn() {
|
||||
const t = ru.emias;
|
||||
const navigate = useNavigate();
|
||||
const { signIn } = useSession();
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [personas, setPersonas] = useState<DemoPersona[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.demoPersonas()
|
||||
.then(setPersonas)
|
||||
.catch(() => setPersonas([]));
|
||||
}, []);
|
||||
|
||||
const finish = (auth: AuthResult) => {
|
||||
signIn(auth);
|
||||
navigate("/chat", { replace: true });
|
||||
};
|
||||
|
||||
const fail = (e: unknown) => {
|
||||
setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!code.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
finish(await api.signInEmias(code.trim()));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const demo = async (persona: DemoPersona) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
finish(await api.signInEmiasDemo(persona.token));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="page" id="main">
|
||||
<div className="narrow">
|
||||
<BackLink to="/">{t.back}</BackLink>
|
||||
<section className="panel" aria-labelledby="emias-title">
|
||||
<h1 id="emias-title" className="h2">
|
||||
{t.title}
|
||||
</h1>
|
||||
<Notice>{t.demoNotice}</Notice>
|
||||
|
||||
<form className="form" onSubmit={submit} noValidate>
|
||||
<div className={error ? "field field--error" : "field"}>
|
||||
<label htmlFor="code" className="field__label">
|
||||
{t.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
className="input"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t.codePlaceholder}
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
maxLength={32}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? "code-error" : "code-hint"}
|
||||
/>
|
||||
{error ? (
|
||||
<p id="code-error" className="field__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p id="code-hint" className="field__hint">
|
||||
{t.codeHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn--primary btn--block" disabled={busy || !code.trim()}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="divider" role="separator">
|
||||
<span>{t.or}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="h3">{t.demoTitle}</h2>
|
||||
<p className="muted">{t.demoLead}</p>
|
||||
{personas === null ? null : personas.length === 0 ? (
|
||||
<p className="muted">{t.demoEmpty}</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{personas.map((p) => (
|
||||
<li key={p.token}>
|
||||
<button type="button" className="list-row" onClick={() => demo(p)} disabled={busy}>
|
||||
<span className="avatar" aria-hidden="true">
|
||||
{p.display_name.charAt(0)}
|
||||
</span>
|
||||
<span className="list-row__text">
|
||||
<span className="list-row__title">{p.display_name}</span>
|
||||
<span className="list-row__sub">{t.demoDay(p.therapy_day)}</span>
|
||||
</span>
|
||||
<span className="list-row__action">
|
||||
{t.demoAction}
|
||||
<Icon name="arrowRight" size={18} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
|
||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
||||
const WEEK = [38, 52, 46, 60, 58, 72, 80]; // illustrative trend, %
|
||||
|
||||
function Facts({ items }: { items: readonly { ok: boolean; text: string }[] }) {
|
||||
return (
|
||||
<ul className="facts">
|
||||
{items.map((f) => (
|
||||
<li key={f.text} className={f.ok ? "fact" : "fact fact--no"}>
|
||||
<Icon name={f.ok ? "check" : "minus"} size={18} />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Illustration of a day with the service (decorative: the same facts are stated in text).
|
||||
function DayPreview() {
|
||||
const d = ru.welcome.day;
|
||||
return (
|
||||
<figure className="day" aria-hidden="true">
|
||||
<figcaption className="day__caption">{d.caption}</figcaption>
|
||||
<ol className="day__list">
|
||||
<li className="day__item day__item--meds">
|
||||
<span className="day__time">{d.meds.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="pill" size={16} />
|
||||
{d.meds.track}
|
||||
</span>
|
||||
<div className="day__row">
|
||||
<span className="day__text">{d.meds.text}</span>
|
||||
<span className="day__done">
|
||||
<Icon name="check" size={16} />
|
||||
{d.meds.done}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="day__item day__item--mood">
|
||||
<span className="day__time">{d.mood.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="pulse" size={16} />
|
||||
{d.mood.track}
|
||||
</span>
|
||||
<span className="day__text">{d.mood.text}</span>
|
||||
<div className="scale">
|
||||
{SCALE.map((n) => (
|
||||
<span key={n} className={`scale__dot scale__dot--${n}${n === 4 ? " is-picked" : ""}`}>
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="scale__ends">
|
||||
<span>{d.mood.low}</span>
|
||||
<span>{d.mood.high}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="day__item day__item--doctor">
|
||||
<span className="day__time">{d.doctor.time}</span>
|
||||
<div className="day__card">
|
||||
<span className="day__track">
|
||||
<Icon name="doctor" size={16} />
|
||||
{d.doctor.track}
|
||||
</span>
|
||||
<div className="day__row">
|
||||
<span className="day__text">{d.doctor.text}</span>
|
||||
<span className="spark">
|
||||
{WEEK.map((v, i) => (
|
||||
<i key={i} style={{ height: `${v}%` }} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export function Welcome() {
|
||||
const navigate = useNavigate();
|
||||
const t = ru.welcome;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="welcome" id="main">
|
||||
<div className="welcome__inner">
|
||||
<section className="welcome__intro" aria-labelledby="welcome-title">
|
||||
<h1 id="welcome-title" className="display">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</section>
|
||||
|
||||
<section className="signin" aria-labelledby="choice-title">
|
||||
<h2 id="choice-title" className="signin__title">
|
||||
{t.choose}
|
||||
</h2>
|
||||
|
||||
<article className="option option--emias">
|
||||
<div className="option__head">
|
||||
<span className="option__icon">
|
||||
<Icon name="doctor" size={22} />
|
||||
</span>
|
||||
<h3 className="option__title">{t.emias.title}</h3>
|
||||
<span className="badge">{t.emias.badge}</span>
|
||||
</div>
|
||||
<Facts items={t.emias.facts} />
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={() => navigate("/emias")}>
|
||||
{t.emias.action}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article className="option option--anon">
|
||||
<div className="option__head">
|
||||
<span className="option__icon">
|
||||
<Icon name="lock" size={22} />
|
||||
</span>
|
||||
<h3 className="option__title">{t.anonymous.title}</h3>
|
||||
</div>
|
||||
<Facts items={t.anonymous.facts} />
|
||||
<button type="button" className="btn btn--secondary btn--block" onClick={() => navigate("/anonymous")}>
|
||||
{t.anonymous.action}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="welcome__aside">
|
||||
<DayPreview />
|
||||
<p className="disclaimer">{t.disclaimer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user