page.tsx146 lines · main
1import Link from 'next/link';
2
3import { LifeBuoyIcon } from '@/components/ui/life-buoy';
4
5import { apiJson } from '@/lib/api';
6import { toValidDate } from '@/lib/utils';
7
8import { EmptyState } from '../_components/empty-state';
9import { Section } from '../_components/section';
10import { StatusFilter } from './status-filter';
11
12export const metadata = { title: 'admin · tickets' };
13export const dynamic = 'force-dynamic';
14
15interface AdminTicket {
16 id: string;
17 ticketNumber: string;
18 status: string;
19 topic: string;
20 topicCode: string | null;
21 name: string;
22 email: string;
23 subject: string;
24 message: string;
25 country: string | null;
26 assignedTo: string | null;
27 operatorNotes: string;
28 createdAt: string;
29 handledAt: string | null;
30}
31
32const STATUS_LABELS: Record<string, string> = {
33 no_response: 'no response',
34 in_review: 'in review',
35 replied: 'replied',
36 closed: 'closed',
37};
38
39const VALID_STATUSES = ['no_response', 'in_review', 'replied', 'closed'] as const;
40
41function statusBadgeClass(status: string): string {
42 switch (status) {
43 case 'in_review':
44 return 'border-[var(--color-warning)] text-[var(--color-warning)]';
45 case 'replied':
46 return 'border-[var(--color-primary)] text-[var(--color-primary)]';
47 case 'closed':
48 return 'border-[var(--color-border)] text-[var(--color-text-muted)]';
49 default: // no_response
50 return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]';
51 }
52}
53
54export default async function AdminTicketsPage({
55 searchParams,
56}: {
57 searchParams: Promise<{ status?: string }>;
58}) {
59 const { status } = await searchParams;
60 const validStatus = VALID_STATUSES.includes(status as never) ? status : undefined;
61
62 const qs = validStatus ? `?status=${validStatus}&limit=200` : '?limit=200';
63 const { tickets } = await apiJson<{ tickets: AdminTicket[] }>(
64 `/v1/admin/tickets${qs}`,
65 ).catch(() => ({ tickets: [] as AdminTicket[] }));
66
67 return (
68 <div className="flex flex-col gap-10">
69 <header className="flex flex-col gap-2">
70 <div className="flex items-center gap-2">
71 <span className="text-[var(--color-primary)]">
72 <LifeBuoyIcon size={20} />
73 </span>
74 <h1 className="font-mono text-xl tracking-tight">support tickets</h1>
75 </div>
76 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
77 incoming support tickets from the contact form. triage newest-first; change status as
78 you review / reply / close. click a ticket to open the full thread.
79 </p>
80 </header>
81
82 <StatusFilter current={validStatus} />
83
84 <Section title={`tickets · ${tickets.length}`} icon={<LifeBuoyIcon size={16} />}>
85 {tickets.length === 0 ? (
86 <EmptyState
87 icon={<LifeBuoyIcon size={28} />}
88 title={
89 validStatus
90 ? `no tickets with status "${STATUS_LABELS[validStatus] ?? validStatus}"`
91 : 'no tickets yet — the queue is clear'
92 }
93 message={
94 validStatus
95 ? 'try another filter, or "all" for the full list.'
96 : 'new contact-form submissions land here the moment they arrive.'
97 }
98 />
99 ) : (
100 <ul className="flex flex-col gap-6">
101 {tickets.map((t) => {
102 const created = toValidDate(t.createdAt);
103 return (
104 <li key={t.id}>
105 <Link
106 href={`/admin/tickets/${t.id}`}
107 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)]"
108 >
109 <div className="flex flex-wrap items-center gap-2">
110 <span
111 className={`rounded-full border px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${statusBadgeClass(t.status)}`}
112 >
113 {STATUS_LABELS[t.status] ?? t.status}
114 </span>
115 <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)]">
116 {t.topicCode ?? t.topic}
117 </span>
118 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
119 {t.name}
120 </span>
121 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">
122 ·{' '}
123 {created
124 ? `${created.toISOString().slice(0, 16).replace('T', ' ')} utc`
125 : '—'}
126 </span>
127 <span className="ml-auto font-mono text-[10px] text-[var(--color-primary)]">
128 {t.ticketNumber}
129 </span>
130 </div>
131 <p className="font-mono text-sm text-[var(--color-text)]">
132 {t.subject || '(no subject)'}
133 </p>
134 <p className="line-clamp-2 font-mono text-[11px] text-[var(--color-text-subtle)]">
135 {t.message}
136 </p>
137 </Link>
138 </li>
139 );
140 })}
141 </ul>
142 )}
143 </Section>
144 </div>
145 );
146}