b17984fbef
Outbound (already existed one-way: ticket create -> Gitea issue) now also mirrors status changes: closing/reopening a ticket in the admin UI closes/reopens the linked Gitea issue, and replies posted from Epicure (by the ticket owner or an admin) post as a comment on the issue. Captures and stores the Gitea issue number at creation time to address these follow-up calls without re-parsing the issue URL. Inbound: new webhook receiver at /api/webhooks/gitea, verified via HMAC-SHA256 signature (X-Gitea-Signature) with a configurable GITEA_WEBHOOK_SECRET site setting, deduped by delivery id the same way the existing Stripe receiver dedupes events. Handles "issues" (closed/reopened -> ticket status) and "issue_comment" (created -> appends to the ticket's comment thread), skipping comments Epicura already posted itself (matched by Gitea comment id) to avoid loops. New support_ticket_comments table backs a lightweight conversation thread on both the user-facing support page and the admin support manager, each comment tagged by author (user/admin/gitea). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useTranslations } from "next-intl";
|
|
import { toast } from "sonner";
|
|
import { ExternalLink, Paperclip, X, FileText, MessageSquare } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { uploadToPresignedPost } from "@/lib/upload-client";
|
|
|
|
type TicketType = "bug" | "suggestion" | "question";
|
|
type TicketStatus = "open" | "triaged" | "closed";
|
|
|
|
type Attachment = { id: string; contentType: string; url: string };
|
|
|
|
type Comment = { id: string; authorType: "user" | "admin" | "gitea"; body: string; createdAt: string };
|
|
|
|
type Ticket = {
|
|
id: string;
|
|
type: TicketType;
|
|
title: string;
|
|
description: string;
|
|
status: TicketStatus;
|
|
giteaIssueUrl: string | null;
|
|
createdAt: string;
|
|
attachments: Attachment[];
|
|
};
|
|
|
|
type PendingAttachment = {
|
|
key: string;
|
|
contentType: string;
|
|
sizeBytes: number;
|
|
preview: string | null;
|
|
name: string;
|
|
};
|
|
|
|
const MAX_ATTACHMENTS = 5;
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
const ACCEPT = "image/jpeg,image/png,image/webp,image/avif,image/gif,application/pdf,text/plain,application/json,application/zip";
|
|
|
|
function formatDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
function isImage(contentType: string) {
|
|
return contentType.startsWith("image/");
|
|
}
|
|
|
|
export function SupportManager({
|
|
initialTickets,
|
|
prefill,
|
|
}: {
|
|
initialTickets: Ticket[];
|
|
prefill?: { type: TicketType; title: string };
|
|
}) {
|
|
const t = useTranslations("support");
|
|
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
|
const [type, setType] = useState<TicketType>(prefill?.type ?? "bug");
|
|
const [title, setTitle] = useState(prefill?.title ?? "");
|
|
const [description, setDescription] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
|
const [uploadingCount, setUploadingCount] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [comments, setComments] = useState<Record<string, Comment[]>>({});
|
|
const [commentDraft, setCommentDraft] = useState("");
|
|
const [sendingComment, setSendingComment] = useState(false);
|
|
|
|
async function toggleConversation(ticketId: string) {
|
|
if (expandedId === ticketId) {
|
|
setExpandedId(null);
|
|
return;
|
|
}
|
|
setExpandedId(ticketId);
|
|
setCommentDraft("");
|
|
if (!comments[ticketId]) {
|
|
try {
|
|
const res = await fetch(`/api/v1/support/${ticketId}/comments`);
|
|
if (res.ok) {
|
|
const data = (await res.json()) as Comment[];
|
|
setComments((prev) => ({ ...prev, [ticketId]: data }));
|
|
}
|
|
} catch {
|
|
// leave the section empty — the send box below still works
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendComment(ticketId: string) {
|
|
const body = commentDraft.trim();
|
|
if (!body) return;
|
|
setSendingComment(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/support/${ticketId}/comments`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
const comment = (await res.json()) as Comment;
|
|
setComments((prev) => ({ ...prev, [ticketId]: [...(prev[ticketId] ?? []), comment] }));
|
|
setCommentDraft("");
|
|
} catch {
|
|
toast.error(t("commentSendFailed"));
|
|
} finally {
|
|
setSendingComment(false);
|
|
}
|
|
}
|
|
|
|
async function handleFiles(files: FileList) {
|
|
const room = MAX_ATTACHMENTS - pendingAttachments.length;
|
|
const toUpload = Array.from(files).slice(0, Math.max(0, room));
|
|
if (files.length > toUpload.length) {
|
|
toast.error(t("attachmentLimitReached", { count: MAX_ATTACHMENTS }));
|
|
}
|
|
setUploadingCount((c) => c + toUpload.length);
|
|
for (const file of toUpload) {
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
toast.error(t("attachmentTooLarge", { name: file.name }));
|
|
setUploadingCount((c) => c - 1);
|
|
continue;
|
|
}
|
|
try {
|
|
const res = await fetch("/api/v1/support/attachments/presign", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ contentType: file.type, fileSize: file.size }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
const { url, fields, key } = (await res.json()) as { url: string; fields: Record<string, string>; key: string };
|
|
const uploaded = await uploadToPresignedPost(url, fields, file);
|
|
if (!uploaded) throw new Error();
|
|
setPendingAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
key,
|
|
contentType: file.type,
|
|
sizeBytes: file.size,
|
|
preview: isImage(file.type) ? URL.createObjectURL(file) : null,
|
|
name: file.name,
|
|
},
|
|
]);
|
|
} catch {
|
|
toast.error(t("attachmentUploadFailed", { name: file.name }));
|
|
} finally {
|
|
setUploadingCount((c) => c - 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function removePending(key: string) {
|
|
setPendingAttachments((prev) => prev.filter((a) => a.key !== key));
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (title.trim().length < 3 || description.trim().length < 10) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch("/api/v1/support", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type,
|
|
title: title.trim(),
|
|
description: description.trim(),
|
|
attachments: pendingAttachments.map((a) => ({
|
|
key: a.key,
|
|
contentType: a.contentType,
|
|
sizeBytes: a.sizeBytes,
|
|
})),
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const data = (await res.json()) as { error?: string };
|
|
throw new Error(data.error ?? t("submitFailed"));
|
|
}
|
|
const ticket = (await res.json()) as Ticket;
|
|
setTickets((prev) => [ticket, ...prev]);
|
|
setTitle("");
|
|
setDescription("");
|
|
setType("bug");
|
|
setPendingAttachments([]);
|
|
toast.success(t("submitSuccess"));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t("submitFailed"));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
|
|
open: "default",
|
|
triaged: "secondary",
|
|
closed: "outline",
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="support-type">{t("typeLabel")}</Label>
|
|
<Select value={type} onValueChange={(v) => setType(v as TicketType)}>
|
|
<SelectTrigger id="support-type" className="w-full sm:w-64">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="bug">{t("typeBug")}</SelectItem>
|
|
<SelectItem value="suggestion">{t("typeSuggestion")}</SelectItem>
|
|
<SelectItem value="question">{t("typeQuestion")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="support-title">{t("titleLabel")}</Label>
|
|
<Input
|
|
id="support-title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t("titlePlaceholder")}
|
|
maxLength={200}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="support-description">{t("descriptionLabel")}</Label>
|
|
<Textarea
|
|
id="support-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t("descriptionPlaceholder")}
|
|
maxLength={5000}
|
|
rows={6}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t("attachmentsLabel")}</Label>
|
|
<div className="flex flex-wrap gap-3">
|
|
{pendingAttachments.map((a) => (
|
|
<div key={a.key} className="relative group h-16 w-16">
|
|
{a.preview ? (
|
|
<Image
|
|
src={a.preview}
|
|
unoptimized
|
|
alt={a.name}
|
|
fill
|
|
className="rounded-lg object-cover border"
|
|
/>
|
|
) : (
|
|
<div className="h-full w-full rounded-lg border flex flex-col items-center justify-center gap-0.5 bg-muted/40 px-1">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-[9px] text-muted-foreground truncate w-full text-center">{a.name}</span>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => removePending(a.key)}
|
|
className="absolute -top-1.5 -right-1.5 rounded-full bg-destructive text-destructive-foreground p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title={t("removeAttachment")}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{pendingAttachments.length < MAX_ATTACHMENTS && (
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={uploadingCount > 0}
|
|
className="h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
|
|
>
|
|
<Paperclip className="h-4 w-4" />
|
|
<span className="text-[10px]">{uploadingCount > 0 ? t("attachmentUploading") : t("attachmentAdd")}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t("attachmentsHint", { count: MAX_ATTACHMENTS })}</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={ACCEPT}
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
|
/>
|
|
</div>
|
|
|
|
<Button type="submit" disabled={submitting || uploadingCount > 0 || title.trim().length < 3 || description.trim().length < 10}>
|
|
{submitting ? t("submitting") : t("submitButton")}
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="space-y-3">
|
|
<h2 className="font-semibold text-lg">{t("historyTitle")}</h2>
|
|
{tickets.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t("noTickets")}</p>
|
|
) : (
|
|
<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">
|
|
<p className="text-sm font-medium">{ticket.title}</p>
|
|
<Badge variant={statusVariant[ticket.status]} className="text-xs shrink-0">
|
|
{t(`status.${ticket.status}`)}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
|
{ticket.attachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{ticket.attachments.map((a) => (
|
|
<Link
|
|
key={a.id}
|
|
href={a.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="relative h-12 w-12 rounded-md border overflow-hidden block"
|
|
>
|
|
{isImage(a.contentType) ? (
|
|
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center bg-muted/40">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1">
|
|
<Badge variant="outline" className="text-xs">{t(`type.${ticket.type}`)}</Badge>
|
|
<span>{formatDate(ticket.createdAt)}</span>
|
|
{ticket.giteaIssueUrl && (
|
|
<Link
|
|
href={ticket.giteaIssueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
{t("viewIssue")}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => { void toggleConversation(ticket.id); }}
|
|
className="flex items-center gap-1 hover:text-foreground"
|
|
>
|
|
<MessageSquare className="h-3 w-3" />
|
|
{t("conversation")}
|
|
</button>
|
|
</div>
|
|
|
|
{expandedId === ticket.id && (
|
|
<div className="mt-2 space-y-2 rounded-md border bg-muted/30 p-3">
|
|
{(comments[ticket.id] ?? []).length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">{t("noComments")}</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{(comments[ticket.id] ?? []).map((c) => (
|
|
<div key={c.id} className="text-sm">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t(`commentAuthor.${c.authorType}`)}
|
|
</span>
|
|
<p className="whitespace-pre-wrap">{c.body}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
<Textarea
|
|
value={commentDraft}
|
|
onChange={(e) => setCommentDraft(e.target.value)}
|
|
placeholder={t("commentPlaceholder")}
|
|
maxLength={3000}
|
|
rows={2}
|
|
className="text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={sendingComment || !commentDraft.trim()}
|
|
onClick={() => { void sendComment(ticket.id); }}
|
|
>
|
|
{t("commentSend")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|