Repository Public
1"use client";2import { useEffect, useRef, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminConfig } from "@/i18n/admin-config";5import { api } from "@/lib/client-api";6import { fmtDateTime } from "@/lib/format";7import { Badge, Button, Empty, errorText, Field, Notice, toast } from "@/components/ui";8import { Loadable, useAdmin, useLoad } from "@/components/admin/shell";9import { Section } from "./common";1011interface Job { id: string; kind: string; status: string; to: string; attempts: number; runAt: string; sentAt: string | null; error: string | null; transport: string | null; messageId: string | null; createdAt: string }12interface Data { mail: { configured: boolean; transport: string; reason: string | null }; jobs: Job[] }13type Tone = "pending" | "ok" | "danger" | "info" | "mute";1415export function NotificationsTab() {16 const t = useT(adminConfig);17 const locale = useLocale();18 const { timezone, email } = useAdmin();19 const state = useLoad<Data>("/api/v1/admin/notifications");20 const [to, setTo] = useState(email);21 const [sending, setSending] = useState(false);22 const [requeueing, setRequeueing] = useState<string | null>(null);23 const [watching, setWatching] = useState(false);24 const timer = useRef<ReturnType<typeof setInterval> | null>(null);25 const reload = state.reload;26 useEffect(() => () => { if (timer.current) clearInterval(timer.current); }, []);2728 /** After an action, the worker needs a few seconds: refresh the log every 5 s for 30 s. */29 const watch = () => {30 if (timer.current) clearInterval(timer.current);31 let n = 0; setWatching(true);32 timer.current = setInterval(() => { void reload(); if (++n >= 6) { clearInterval(timer.current!); timer.current = null; setWatching(false); } }, 5000);33 };34 const test = async (ev: React.FormEvent) => {35 ev.preventDefault(); setSending(true);36 try { await api("/api/v1/admin/notifications/test", { body: { to: to.trim() } }); toast.info(t("ntTestQueued")); await reload(); watch(); }37 catch (e) { toast.error(errorText(e, locale)); } finally { setSending(false); }38 };39 const requeue = async (ids?: string[]) => {40 setRequeueing(ids ? ids[0] : "all");41 try { const r = await api<{ requeued: number }>("/api/v1/admin/notifications/requeue", { body: ids ? { ids } : {} }); toast.ok(t("ntRequeued", { n: r.requeued })); await reload(); watch(); }42 catch (e) { toast.error(errorText(e, locale)); } finally { setRequeueing(null); }43 };4445 const kinds: Record<string, string> = {46 request_received: t("ntKRequestReceived"), booking_confirmed: t("ntKBookingConfirmed"), request_declined: t("ntKRequestDeclined"), reschedule_declined: t("ntKRescheduleDeclined"),47 proposal: t("ntKProposal"), booking_cancelled: t("ntKBookingCancelled"), booking_rescheduled: t("ntKBookingRescheduled"), request_expired: t("ntKRequestExpired"), reminder: t("ntKReminder"),48 admin_new_request: t("ntKAdminNewRequest"), admin_new_booking: t("ntKAdminNewBooking"), admin_visitor_cancelled: t("ntKAdminVisitorCancelled"), admin_request_withdrawn: t("ntKAdminWithdrawn"),49 admin_reschedule_request: t("ntKAdminReschedule"), admin_proposal_accepted: t("ntKAdminAccepted"), test: t("ntKTest"),50 };51 const status = (j: Job): { tone: Tone; label: string } => {52 switch (j.status) {53 case "sent": return { tone: "ok", label: t("ntSSent") };54 case "queued": return j.attempts > 0 ? { tone: "pending", label: t("ntSRetrying") } : { tone: "info", label: t("ntSQueued") };55 case "sending": return { tone: "info", label: t("ntSSending") };56 case "dead": return { tone: "danger", label: t("ntSDead") };57 case "unconfigured": return { tone: "pending", label: t("ntSUnconfigured") };58 case "cancelled": return { tone: "mute", label: t("ntSCancelled") };59 default: return { tone: "mute", label: j.status };60 }61 };62 const d = state.data;63 const parked = d?.jobs.filter((j) => j.status === "unconfigured" || j.status === "dead") ?? [];6465 return (66 <Loadable state={state}>67 {d ? (68 <div className="grid gap-5">69 <Section title={t("ntTransportTitle")}>70 {d.mail.configured71 ? <Notice tone="ok">{t("ntConfigured", { transport: d.mail.transport })}</Notice>72 : <Notice tone="warn"><p className="font-semibold">{t("ntNotConfigured")}</p><p className="mt-1">{t("ntNotConfiguredLead")}</p>{d.mail.reason ? <p className="mt-1 font-mono text-[13px] break-words">{d.mail.reason}</p> : null}</Notice>}73 <form onSubmit={test} className="mt-4 flex flex-wrap items-end gap-3">74 <div className="flex-1 min-w-[220px] max-w-sm"><Field label={t("ntTestTo")}>{(p) => <input {...p} type="email" className="input" required value={to} maxLength={254} onChange={(e) => setTo(e.target.value)} />}</Field></div>75 <Button type="submit" icon="mail" busy={sending}>{t("ntTestSend")}</Button>76 </form>77 <p className="hint">{t("ntTestHint")}</p>78 </Section>7980 <Section title={t("ntLogTitle")} lead={t("ntLogLead")}81 actions={<>{watching ? <Badge tone="info">{t("ntWatching")}</Badge> : null}<Button small icon="refresh" busy={state.loading} onClick={() => reload()}>{t("cRefresh")}</Button>{parked.length ? <Button small busy={requeueing === "all"} onClick={() => requeue()}>{t("ntRequeueAll", { n: parked.length })}</Button> : null}</>}>82 {d.jobs.length === 0 ? <Empty icon="mail" title={t("ntEmpty")} /> : (83 <ul>84 {d.jobs.map((j) => {85 const st = status(j);86 return (87 <li key={j.id} className="py-3 border-t border-line first:border-t-0 grid gap-1.5">88 <div className="flex flex-wrap items-center justify-between gap-2">89 <span className="font-medium">{kinds[j.kind] ?? j.kind}</span>90 <Badge tone={st.tone}>{st.label}</Badge>91 </div>92 <p className="t-mute text-sm break-all">{j.to}</p>93 <p className="t-mute text-[13px] flex flex-wrap gap-x-4 gap-y-0.5">94 <span>{t("ntCreated", { date: fmtDateTime(j.createdAt, timezone, locale) })}</span>95 {j.sentAt ? <span>{t("ntSentAt", { date: fmtDateTime(j.sentAt, timezone, locale) })}</span> : null}96 {j.status === "queued" ? <span>{t("ntNextTry", { date: fmtDateTime(j.runAt, timezone, locale) })}</span> : null}97 <span>{t("ntAttempts", { n: j.attempts })}</span>98 {j.transport ? <span>{j.transport}</span> : null}99 </p>100 {j.messageId ? <p className="t-mute text-[12.5px] font-mono break-all">{t("ntMessageId")} {j.messageId}</p> : null}101 {j.error ? <p className="text-[13px] text-danger break-words">{j.error}</p> : null}102 {j.status === "unconfigured" || j.status === "dead" ? <div><Button small busy={requeueing === j.id} onClick={() => requeue([j.id])}>{t("ntRequeue")}</Button></div> : null}103 </li>104 );105 })}106 </ul>107 )}108 </Section>109 </div>110 ) : null}111 </Loadable>112 );113}114