import { useEffect, useRef, 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 { Toasts } from "./Toasts.tsx"; import { pushToast } from "../lib/toasts.ts"; 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(""); const threadRef = useRef(null); // Message ids already applied (WS echo + HTTP response race): each lands once. const seenIds = useRef(new Set()); function appendMessage(msg: AccessMessage): boolean { if (seenIds.current.has(msg.id)) return false; seenIds.current.add(msg.id); setMessages((prev) => [...prev, msg]); return true; } // Follow live traffic: stick to the bottom while the user already is. useEffect(() => { const el = threadRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 96; if (nearBottom) el.scrollTop = el.scrollHeight; }, [messages]); async function load() { try { const res = await api.accessTicket(ticketId); for (const m of res.messages) seenIds.current.add(m.id); 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); for (const m of res.messages) seenIds.current.add(m.id); setRequest(res.request); setMessages(res.messages); } catch { // Transient failure: keep last known state. } } useEffect(() => { load(); // Live updates over WebSocket with full payloads: admin replies and the // decision apply straight to state (no refetch); resync on (re)connect // covers events missed while offline. const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), { onEvent: (evt) => { // Report events only travel the admin firehose — never ticket sockets. if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return; if (evt.request_id !== ticketId) return; if (evt.type === "access_message") { if (appendMessage(evt.message) && evt.message.author === "admin") { pushToast(t("live.adminReply"), { icon: "mail" }); } } else if (evt.type === "access_status") { setRequest(evt.request); pushToast(t("live.ticketStatus", { status: t(`review.status.${evt.request.status}` as StringKey) }), { icon: "inbox" }); } }, 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); appendMessage(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")}

) : (