page.tsx186 lines · main
1import Link from 'next/link';
2
3import { LifeBuoyIcon } from '@/components/ui/life-buoy';
4import { MailIcon } from '@/components/ui/mail';
5
6import { apiJson } from '@/lib/api';
7import { toValidDate } from '@/lib/utils';
8
9import { EmptyState } from '../_components/empty-state';
10import { Section } from '../_components/section';
11import { StatCard } from '../_components/stat-card';
12import { MessageFilter } from './message-filter';
13
14export const metadata = { title: 'admin · messages' };
15export const dynamic = 'force-dynamic';
16
17interface SerializedMessage {
18 id: string;
19 ticketNumber: string | null;
20 isTicket: boolean;
21 status: string;
22 topic: string;
23 topicCode: string | null;
24 name: string;
25 email: string;
26 subject: string | null;
27 message: string;
28 country: string | null;
29 assignedTo: string | null;
30 operatorNotes: string | null;
31 createdAt: string;
32 handledAt: string | null;
33}
34
35const VALID_FILTERS = ['all', 'plain', 'tickets'] as const;
36type Filter = (typeof VALID_FILTERS)[number];
37
38const STATUS_LABELS: Record<string, string> = {
39 no_response: 'no response',
40 in_review: 'in review',
41 replied: 'replied',
42 closed: 'closed',
43};
44
45function statusBadgeClass(status: string): string {
46 switch (status) {
47 case 'in_review':
48 return 'border-[var(--color-warning)] text-[var(--color-warning)]';
49 case 'replied':
50 return 'border-[var(--color-primary)] text-[var(--color-primary)]';
51 case 'closed':
52 return 'border-[var(--color-border)] text-[var(--color-text-muted)]';
53 default: // no_response
54 return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]';
55 }
56}
57
58export default async function AdminMessagesPage({
59 searchParams,
60}: {
61 searchParams: Promise<{ filter?: string }>;
62}) {
63 const { filter } = await searchParams;
64 const validFilter: Filter = VALID_FILTERS.includes(filter as never)
65 ? (filter as Filter)
66 : 'all';
67
68 const { messages } = await apiJson<{ messages: SerializedMessage[] }>(
69 `/v1/admin/contact-messages?filter=${validFilter}`,
70 ).catch(() => ({ messages: [] as SerializedMessage[] }));
71
72 const total = messages.length;
73 const ticketCount = messages.filter((m) => m.isTicket).length;
74 const plainCount = total - ticketCount;
75
76 return (
77 <div className="flex flex-col gap-10">
78 <header className="flex flex-col gap-2">
79 <div className="flex items-center gap-2">
80 <span className="text-[var(--color-primary)]">
81 <MailIcon size={20} />
82 </span>
83 <h1 className="font-mono text-xl tracking-tight">messages</h1>
84 </div>
85 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
86 every message from the contact form — tagged ones are also tracked as tickets; plain
87 ones live only here.
88 </p>
89 </header>
90
91 <div className="grid gap-6 sm:grid-cols-3">
92 <StatCard
93 label={validFilter === 'all' ? 'total messages' : `messages · ${validFilter}`}
94 value={total}
95 icon={<MailIcon size={16} />}
96 hint="in the current view"
97 />
98 <StatCard
99 label="plain (no ticket)"
100 value={plainCount}
101 hint="live only in the inbox"
102 />
103 <StatCard
104 label="tickets"
105 value={ticketCount}
106 tone="primary"
107 icon={<LifeBuoyIcon size={16} />}
108 hint="also tracked as support tickets"
109 />
110 </div>
111
112 <MessageFilter current={validFilter} />
113
114 <Section title={`inbox · ${total}`} icon={<MailIcon size={16} />}>
115 {total === 0 ? (
116 <EmptyState
117 icon={<MailIcon size={28} />}
118 title="inbox is clear"
119 message={
120 validFilter === 'all'
121 ? 'new contact-form submissions land here the moment they arrive.'
122 : 'nothing under this filter — try "all" for the full inbox.'
123 }
124 />
125 ) : (
126 <ul className="flex flex-col gap-6">
127 {messages.map((m) => {
128 const created = toValidDate(m.createdAt);
129 return (
130 <li key={m.id}>
131 <Link
132 href={`/admin/messages/${m.id}`}
133 className="flex flex-col gap-3 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 transition-colors hover:border-[var(--color-border-strong)]"
134 >
135 <div className="flex flex-wrap items-center gap-2">
136 <span
137 className={`rounded-full border px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${statusBadgeClass(m.status)}`}
138 >
139 {STATUS_LABELS[m.status] ?? m.status}
140 </span>
141 <span className="rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
142 {m.topicCode ?? m.topic}
143 </span>
144 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
145 {m.name}
146 </span>
147 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">
148 · {m.email}
149 </span>
150 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">
151 ·{' '}
152 {created
153 ? `${created.toISOString().slice(0, 16).replace('T', ' ')} utc`
154 : '—'}
155 </span>
156 {m.isTicket && m.ticketNumber ? (
157 <span className="ml-auto rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
158 ticket {m.ticketNumber}
159 </span>
160 ) : (
161 <span className="ml-auto rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
162 plain message
163 </span>
164 )}
165 </div>
166 <p className="font-mono text-sm text-[var(--color-text)]">
167 {m.subject || '(no subject)'}
168 </p>
169 <p className="line-clamp-2 font-mono text-[11px] text-[var(--color-text-subtle)]">
170 {m.message}
171 </p>
172 {m.country ? (
173 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
174 country: {m.country}
175 </p>
176 ) : null}
177 </Link>
178 </li>
179 );
180 })}
181 </ul>
182 )}
183 </Section>
184 </div>
185 );
186}