page.tsx194 lines · main
1import Link from 'next/link';
2import { notFound } from 'next/navigation';
3
4import { LifeBuoyIcon } from '@/components/ui/life-buoy';
5import { MailIcon } from '@/components/ui/mail';
6
7import { apiJson } from '@/lib/api';
8import { toValidDate } from '@/lib/utils';
9
10import { Section } from '../../_components/section';
11import { MessageActions } from './message-actions';
12
13export const dynamic = 'force-dynamic';
14
15interface SerializedMessage {
16 id: string;
17 ticketNumber: string | null;
18 isTicket: boolean;
19 status: string;
20 topic: string;
21 topicCode: string | null;
22 name: string;
23 email: string;
24 subject: string | null;
25 message: string;
26 country: string | null;
27 assignedTo: string | null;
28 operatorNotes: string | null;
29 createdAt: string;
30 handledAt: string | null;
31}
32
33interface Reply {
34 id: string;
35 author: 'operator' | 'user';
36 body: string;
37 createdAt: string;
38}
39
40function publicApiOrigin(): string {
41 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
42}
43
44function statusLabel(s: string): string {
45 switch (s) {
46 case 'no_response':
47 return 'no response';
48 case 'in_review':
49 return 'in review';
50 case 'replied':
51 return 'replied';
52 case 'closed':
53 return 'closed';
54 default:
55 return s;
56 }
57}
58
59function statusBadgeClass(status: string): string {
60 switch (status) {
61 case 'in_review':
62 return 'border-[var(--color-warning)] text-[var(--color-warning)]';
63 case 'replied':
64 return 'border-[var(--color-primary)] text-[var(--color-primary)]';
65 case 'closed':
66 return 'border-[var(--color-border)] text-[var(--color-text-muted)]';
67 default:
68 return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]';
69 }
70}
71
72export default async function AdminMessageDetailPage({
73 params,
74}: {
75 params: Promise<{ id: string }>;
76}) {
77 const { id } = await params;
78
79 const result = await apiJson<{ message: SerializedMessage; replies: Reply[] }>(
80 `/v1/admin/contact-messages/${id}`,
81 ).catch(() => null);
82
83 if (!result) notFound();
84
85 const { message, replies } = result;
86 const created = toValidDate(message.createdAt);
87 const handled = toValidDate(message.handledAt);
88
89 return (
90 <div className="flex max-w-3xl flex-col gap-10">
91 <Link
92 href="/admin/messages"
93 className="font-mono text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]"
94 >
95 ← inbox
96 </Link>
97
98 {/* header */}
99 <header className="flex flex-col gap-2">
100 <div className="flex flex-wrap items-center gap-3">
101 <span className="text-[var(--color-primary)]">
102 <MailIcon size={20} />
103 </span>
104 <span
105 className={`rounded-full border px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${statusBadgeClass(message.status)}`}
106 >
107 {statusLabel(message.status)}
108 </span>
109 {message.isTicket && message.ticketNumber ? (
110 <Link
111 href="/admin/tickets"
112 className="inline-flex items-center gap-1 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)] transition-colors hover:border-[var(--color-primary)]"
113 >
114 <LifeBuoyIcon size={12} />
115 ticket {message.ticketNumber}
116 </Link>
117 ) : (
118 <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)]">
119 plain message
120 </span>
121 )}
122 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
123 {created ? `${created.toISOString().slice(0, 16).replace('T', ' ')} utc` : '—'}
124 </span>
125 </div>
126 <h1 className="font-mono text-xl tracking-tight text-[var(--color-text)]">
127 {message.subject || '(no subject)'}
128 </h1>
129 <div className="flex flex-wrap gap-4 font-mono text-[10px] text-[var(--color-text-subtle)]">
130 <span>
131 from: {message.name} · {message.email}
132 </span>
133 {message.country ? <span>country: {message.country}</span> : <span>country: —</span>}
134 {message.topicCode ? (
135 <span>topic: {message.topicCode}</span>
136 ) : (
137 <span>topic: {message.topic}</span>
138 )}
139 {handled ? (
140 <span>handled: {handled.toISOString().slice(0, 16).replace('T', ' ')} utc</span>
141 ) : null}
142 </div>
143 </header>
144
145 {/* original message */}
146 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
147 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
148 message · from {message.name}
149 </p>
150 <pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text)]">
151 {message.message}
152 </pre>
153 </div>
154
155 {/* reply thread */}
156 {replies.length > 0 ? (
157 <Section
158 title={`thread · ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`}
159 icon={<MailIcon size={16} />}
160 >
161 <div className="flex flex-col gap-6">
162 {replies.map((r) => {
163 const rd = toValidDate(r.createdAt);
164 return (
165 <div
166 key={r.id}
167 className={`rounded-xl border p-6 ${
168 r.author === 'operator'
169 ? 'border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)]'
170 : 'border-[var(--color-border-subtle)] bg-[var(--color-surface)]'
171 }`}
172 >
173 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
174 {r.author === 'operator' ? 'operator' : 'user'} ·{' '}
175 {rd ? `${rd.toISOString().slice(0, 16).replace('T', ' ')} utc` : '—'}
176 </p>
177 <pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text)]">
178 {r.body}
179 </pre>
180 </div>
181 );
182 })}
183 </div>
184 </Section>
185 ) : null}
186
187 {/* operator actions: reply + status */}
188 <MessageActions
189 message={{ id: message.id, status: message.status }}
190 apiOrigin={publicApiOrigin()}
191 />
192 </div>
193 );
194}