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