page.tsx114 lines · main
1import Link from 'next/link';
2
3import { apiJson } from '@/lib/api';
4import { requireUser } from '@/lib/session';
5import { toValidDate } from '@/lib/utils';
6
7export const metadata = { title: 'my tickets' };
8export const dynamic = 'force-dynamic';
9
10interface UserTicket {
11 id: string;
12 ticketNumber: string;
13 status: string;
14 topic: string;
15 topicCode: string | null;
16 subject: string;
17 message: string;
18 createdAt: string;
19}
20
21const STATUS_LABELS: Record<string, string> = {
22 no_response: 'no response',
23 in_review: 'in review',
24 replied: 'replied',
25 closed: 'closed',
26};
27
28function statusBadgeClass(status: string): string {
29 switch (status) {
30 case 'in_review':
31 return 'border-[var(--color-warning)] text-[var(--color-warning)]';
32 case 'replied':
33 return 'border-[var(--color-primary)] text-[var(--color-primary)]';
34 case 'closed':
35 return 'border-[var(--color-border)] text-[var(--color-text-muted)]';
36 default: // no_response
37 return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]';
38 }
39}
40
41export default async function MyTicketsPage() {
42 await requireUser();
43
44 const { tickets } = await apiJson<{ tickets: UserTicket[] }>('/v1/me/tickets').catch(() => ({
45 tickets: [] as UserTicket[],
46 }));
47
48 return (
49 <div className="flex max-w-3xl flex-col gap-8 pb-12">
50 <header className="flex flex-col gap-2">
51 <div className="flex items-baseline justify-between gap-3">
52 <h1 className="text-2xl font-bold tracking-tight text-[var(--color-text)]">
53 my tickets
54 </h1>
55 <Link
56 href="/dashboard/support"
57 className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
58 >
59 ← contact support
60 </Link>
61 </div>
62 <p className="text-sm text-[var(--color-text-muted)]">
63 support tickets linked to your account. click one to see the full conversation.
64 </p>
65 </header>
66
67 {tickets.length === 0 ? (
68 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-8 text-center">
69 <p className="font-mono text-sm text-[var(--color-text-muted)]">no tickets yet.</p>
70 <Link
71 href="/dashboard/support"
72 className="mt-2 inline-block font-mono text-xs text-[var(--color-primary)] hover:underline"
73 >
74 contact support →
75 </Link>
76 </div>
77 ) : (
78 <ul className="flex flex-col gap-3">
79 {tickets.map((t) => {
80 const created = toValidDate(t.createdAt);
81 return (
82 <li key={t.id}>
83 <Link
84 // '#' is display-only; a '#' in a URL is the fragment marker and 404s the thread.
85 href={`/dashboard/support/tickets/${encodeURIComponent(t.ticketNumber.replace(/^#/, ''))}`}
86 className="flex flex-col gap-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3 transition hover:border-[var(--color-border-strong)]"
87 >
88 <div className="flex flex-wrap items-center gap-2">
89 <span
90 className={`rounded-full border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${statusBadgeClass(t.status)}`}
91 >
92 {STATUS_LABELS[t.status] ?? t.status}
93 </span>
94 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
95 {created
96 ? `${created.toISOString().slice(0, 16).replace('T', ' ')} utc`
97 : '—'}
98 </span>
99 <span className="ml-auto font-mono text-[10px] text-[var(--color-primary)]">
100 {t.ticketNumber}
101 </span>
102 </div>
103 <p className="font-mono text-xs text-[var(--color-text)]">
104 {t.subject || '(no subject)'}
105 </p>
106 </Link>
107 </li>
108 );
109 })}
110 </ul>
111 )}
112 </div>
113 );
114}