811d4cad42
Users can now report bugs, suggestions, or questions from a new /support page. Each submission sends a confirmation email and, when GITEA_URL/ GITEA_TOKEN/GITEA_REPO are configured in admin Settings, opens a labeled issue on that repo automatically (best-effort — failure doesn't block the ticket). Admins get a Support section to triage status and retry failed Gitea issue creation. New support_tickets table, gitea.ts client, site-settings entries for the three new secrets, and OpenAPI docs for the two user-facing endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
import { ExternalLink } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
type TicketStatus = "open" | "triaged" | "closed";
|
|
|
|
type Ticket = {
|
|
id: string;
|
|
userEmail: string;
|
|
username: string | null;
|
|
type: string;
|
|
title: string;
|
|
description: string;
|
|
status: TicketStatus;
|
|
giteaIssueUrl: string | null;
|
|
giteaError: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
function formatDateTime(iso: string) {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
|
|
open: "default",
|
|
triaged: "secondary",
|
|
closed: "outline",
|
|
};
|
|
|
|
export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
|
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
|
const [retrying, setRetrying] = useState<string | null>(null);
|
|
|
|
async function handleStatusChange(id: string, status: TicketStatus) {
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/support/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
setTickets((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)));
|
|
} catch {
|
|
toast.error("Failed to update status");
|
|
}
|
|
}
|
|
|
|
async function handleRetryGitea(id: string) {
|
|
setRetrying(id);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/support/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ retryGitea: true }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
// Re-fetch the single ticket's fields isn't wired up server-side, so
|
|
// reload the list to pick up the new giteaIssueUrl/giteaError.
|
|
const listRes = await fetch("/api/v1/admin/support");
|
|
if (listRes.ok) {
|
|
const data = (await listRes.json()) as Ticket[];
|
|
setTickets(data);
|
|
}
|
|
toast.success("Retried Gitea issue creation");
|
|
} catch {
|
|
toast.error("Retry failed");
|
|
} finally {
|
|
setRetrying(null);
|
|
}
|
|
}
|
|
|
|
if (tickets.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No support tickets yet.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y rounded-md border">
|
|
{tickets.map((ticket) => (
|
|
<div key={ticket.id} className="px-4 py-3 space-y-1.5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">{ticket.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{ticket.username ? `@${ticket.username}` : ticket.userEmail} · {formatDateTime(ticket.createdAt)}
|
|
</p>
|
|
</div>
|
|
<Select value={ticket.status} onValueChange={(v) => { void handleStatusChange(ticket.id, v as TicketStatus); }}>
|
|
<SelectTrigger className="w-32 shrink-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="open">Open</SelectItem>
|
|
<SelectItem value="triaged">Triaged</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
|
<div className="flex items-center gap-2 text-xs flex-wrap pt-1">
|
|
<Badge variant="outline" className="text-xs">{ticket.type}</Badge>
|
|
<Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge>
|
|
{ticket.giteaIssueUrl ? (
|
|
<Link
|
|
href={ticket.giteaIssueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
View Gitea issue
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
) : (
|
|
<>
|
|
{ticket.giteaError && (
|
|
<span className="text-destructive" title={ticket.giteaError}>Gitea issue failed</span>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={retrying === ticket.id}
|
|
onClick={() => { void handleRetryGitea(ticket.id); }}
|
|
>
|
|
{retrying === ticket.id ? "Retrying…" : "Create Gitea issue"}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|