import { useEffect, useState } from "react"; import { CheckCircle2, Clock, MailWarning, MessageSquareText, Search, SendHorizontal, XCircle, } from "lucide-react"; import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { Button, CopyButton, Field, FlowerMark, Input, Modal, Spinner, } from "./ui.tsx"; import { useT, type StringKey } from "../lib/i18n.ts"; import { cn } from "../lib/cn.ts"; const STATUS_STYLE: Record< AccessRequest["status"], { cls: string; icon: typeof Clock } > = { pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock, }, reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search, }, accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2, }, refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle, }, }; function StatusBadge({ status }: { status: AccessRequest["status"] }) { const { t } = useT(); const s = STATUS_STYLE[status]; return ( {t(`review.status.${status}` as StringKey)} ); } /** Public ticket page: /review/:id — argue your case, follow the decision. */ export function ReviewPage({ ticketId }: { ticketId: string }) { const [request, setRequest] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [loading, setLoading] = useState(true); const [invalid, setInvalid] = useState(false); const { t } = useT(); const [sending, setSending] = useState(false); const [error, setError] = useState(""); async function load() { try { const res = await api.accessTicket(ticketId); setRequest(res.request); setMessages(res.messages); } catch { setInvalid(true); } finally { setLoading(false); } } /** Silent resync after a (re)connect — never flips to the error state. */ async function refreshSilent() { try { const res = await api.accessTicket(ticketId); setRequest(res.request); setMessages(res.messages); } catch { // Transient failure: keep last known state. } } useEffect(() => { load(); // Live updates over WebSocket: admin replies and the decision arrive // instantly; resync on (re)connect covers events missed while offline. const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), { onEvent: (evt) => { if (evt.type === "pong" || evt.request_id !== ticketId) return; if (evt.type === "access_message") { const msg = evt.message; setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg])); } else if (evt.type === "access_status") { setRequest(evt.request); } }, onSync: () => { refreshSilent(); }, }); return close; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ticketId]); async function send(e: React.FormEvent) { e.preventDefault(); const body = draft.trim(); if (!body || sending) return; setSending(true); setError(""); try { const res = await api.accessReply(ticketId, body); setMessages((prev) => [...prev, res.message]); setDraft(""); } catch (err) { setError(err instanceof Error ? err.message : t("access.sendFailed")); } finally { setSending(false); } } const closed = request?.status === "accepted" || request?.status === "refused"; return (

{t("review.title")}

{loading && (

{t("review.loadingTicket")}

)} {invalid && (

{" "} {t("review.notFound")}

{t("review.notFoundSub")}

)} {request && (
@{request.username}
{request.reason && (

{t("review.adminNote")} {request.reason}

)} {request.status === "accepted" && ( <>

{t("review.acceptedHint")}

)}
{messages.map((m) => (

{m.author === "admin" ? t("review.admin") : t("review.you")}

{m.body}

))}
{closed ? (

{t("review.closedHint")}

) : (