import { useCallback, useEffect, useRef, useState } from 'react'; import { Mail, RefreshCw } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { readableError } from './errors'; import { useFofosChanged } from './channel'; /** * Messages — what people sent through the contact form. * * The first section built, and chosen for that on purpose: it is one read-only route, so it proves * the whole path — Officer panel, through this plugin's proxy, to fofos and back — with nothing else * in the way to be wrong at the same time. * * **Read only, and there is no delete.** fofos's query layer exposes no way to remove a submission, so * this API cannot offer one. That is the right way round: these are things people sent, and a button * that destroys one should be a decision somebody makes deliberately rather than a control that * appeared because a list looked bare without it. */ type Message = { id: number; name: string; email: string; project_or_company: string | null; idea: string; budget_range: string | null; timeline: string | null; created_at: string | null; }; /** * A date somebody can read, in their own locale, without the seconds nobody wants. * * `undefined` as the locale so the browser uses the reader's, rather than this file deciding that * everyone reads dates the way one developer does. */ const when = (iso: string | null): string => { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); }; export const MessagesPanel = () => { const client = useClient(); // useClient() rebuilds its verbs every render, so it must never sit in a dependency list. const clientRef = useRef(client); clientRef.current = client; const [messages, setMessages] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [signal] = useFofosChanged(); const load = useCallback(async () => { setLoading(true); try { const response = await clientRef.current.get<{ data: Message[] }>('/fofos-admin/messages'); setMessages(response.data); setError(null); } catch (failure) { setError(readableError(failure)); // The previous list is KEPT. A refresh that fails should not blank a screen somebody was // reading — the error says what happened, and what was there is still there. } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load, signal]); if (messages === null && error) { return (

{error}

); } if (messages === null) { return
Loading…
; } return (

{messages.length} message{messages.length === 1 ? '' : 's'}

{/* An error ABOVE the list rather than instead of it, once there is something to show. */} {error &&

{error}

} {messages.length === 0 ? (

Nobody has used the contact form yet.

) : (
    {messages.map((message) => (
  • {message.name}

    {/* A real mailto — replying is the only thing anybody wants to do from here. */} {message.email} {message.project_or_company && ( · {message.project_or_company} )} {/* `whitespace-pre-wrap`, because what somebody typed had line breaks in it and they meant them. */}

    {message.idea}

    {(message.budget_range || message.timeline) && (

    {message.budget_range && Budget: {message.budget_range}} {message.timeline && Timeline: {message.timeline}}

    )}
  • ))}
)}
); };