167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { Input, Label, Textarea } from '@/components/input';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { apiFetch } from '@/lib/api';
|
||
|
|
import { Loader2 } from 'lucide-react';
|
||
|
|
import Link from 'next/link';
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
|
||
|
|
interface Ticket {
|
||
|
|
id: string;
|
||
|
|
subject: string;
|
||
|
|
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
|
||
|
|
createdAt: string;
|
||
|
|
lastMessageAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const STATUS_LABEL: Record<Ticket['status'], string> = {
|
||
|
|
awaiting_admin: 'Open — awaiting support',
|
||
|
|
awaiting_user: 'Reply received',
|
||
|
|
closed: 'Closed',
|
||
|
|
};
|
||
|
|
|
||
|
|
const STATUS_COLOR: Record<Ticket['status'], string> = {
|
||
|
|
awaiting_admin: 'text-amber-300',
|
||
|
|
awaiting_user: 'text-emerald-300',
|
||
|
|
closed: 'text-[--color-fg-subtle]',
|
||
|
|
};
|
||
|
|
|
||
|
|
export default function SupportPage() {
|
||
|
|
const [tickets, setTickets] = useState<Ticket[] | null>(null);
|
||
|
|
const [showNew, setShowNew] = useState(false);
|
||
|
|
const [subject, setSubject] = useState('');
|
||
|
|
const [body, setBody] = useState('');
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
function load() {
|
||
|
|
apiFetch<{ tickets: Ticket[] }>('/v1/support/tickets')
|
||
|
|
.then((r) => setTickets(r.tickets))
|
||
|
|
.catch((e) => setError((e as Error).message));
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(load, []);
|
||
|
|
|
||
|
|
async function createTicket(e: React.FormEvent) {
|
||
|
|
e.preventDefault();
|
||
|
|
setBusy(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
await apiFetch('/v1/support/tickets', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ subject, body }),
|
||
|
|
});
|
||
|
|
setSubject('');
|
||
|
|
setBody('');
|
||
|
|
setShowNew(false);
|
||
|
|
load();
|
||
|
|
} catch (err) {
|
||
|
|
setError((err as Error).message);
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-3xl px-6 py-10">
|
||
|
|
<div className="flex items-baseline justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-[22px] font-semibold tracking-tight">Support</h1>
|
||
|
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||
|
|
Open a ticket and we'll get back to you within one business day.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{!showNew && (
|
||
|
|
<Button variant="primary" size="md" onClick={() => setShowNew(true)}>
|
||
|
|
+ New ticket
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{showNew && (
|
||
|
|
<form onSubmit={createTicket} className="panel mt-6 space-y-4 p-5">
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label htmlFor="t-subject">Subject</Label>
|
||
|
|
<Input
|
||
|
|
id="t-subject"
|
||
|
|
required
|
||
|
|
minLength={3}
|
||
|
|
maxLength={200}
|
||
|
|
value={subject}
|
||
|
|
onChange={(e) => setSubject(e.target.value)}
|
||
|
|
placeholder="Briefly — what's up?"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label htmlFor="t-body" hint={`${body.length} / 10000`}>
|
||
|
|
Message
|
||
|
|
</Label>
|
||
|
|
<Textarea
|
||
|
|
id="t-body"
|
||
|
|
required
|
||
|
|
rows={6}
|
||
|
|
minLength={10}
|
||
|
|
maxLength={10_000}
|
||
|
|
value={body}
|
||
|
|
onChange={(e) => setBody(e.target.value)}
|
||
|
|
placeholder="The more context the better — server slug, error messages, what you expected."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<Button variant="ghost" size="md" type="button" onClick={() => setShowNew(false)}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
size="md"
|
||
|
|
type="submit"
|
||
|
|
disabled={busy || subject.length < 3 || body.length < 10}
|
||
|
|
>
|
||
|
|
{busy ? 'Sending…' : 'Open ticket'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="mt-8">
|
||
|
|
{tickets === null && (
|
||
|
|
<div className="panel p-6 text-center">
|
||
|
|
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{tickets && tickets.length === 0 && !showNew && (
|
||
|
|
<div className="panel p-6 text-center text-[13px] text-[--color-fg-muted]">
|
||
|
|
No tickets yet.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{tickets && tickets.length > 0 && (
|
||
|
|
<div className="panel divide-y divide-[--color-border]">
|
||
|
|
{tickets.map((t) => (
|
||
|
|
<Link
|
||
|
|
key={t.id}
|
||
|
|
href={`/settings/support/${t.id}`}
|
||
|
|
className="flex items-center justify-between px-4 py-3 transition-colors hover:bg-[--color-bg-subtle]"
|
||
|
|
>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate text-[13px] font-medium text-[--color-fg]">
|
||
|
|
{t.subject}
|
||
|
|
</div>
|
||
|
|
<div className={`mt-0.5 text-[11.5px] ${STATUS_COLOR[t.status]}`}>
|
||
|
|
{STATUS_LABEL[t.status]} ·{' '}
|
||
|
|
<span className="text-[--color-fg-subtle]">
|
||
|
|
{new Date(t.lastMessageAt).toLocaleString()}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<span className="ml-3 text-[--color-fg-subtle]">→</span>
|
||
|
|
</Link>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|