"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(initialTickets); const [type, setType] = useState(prefill?.type ?? "bug"); const [title, setTitle] = useState(prefill?.title ?? ""); const [description, setDescription] = useState(""); const [submitting, setSubmitting] = useState(false); const [pendingAttachments, setPendingAttachments] = useState([]); const [uploadingCount, setUploadingCount] = useState(0); const inputRef = useRef(null); const [expandedId, setExpandedId] = useState(null); const [comments, setComments] = useState>({}); 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; 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 = { open: "default", triaged: "secondary", closed: "outline", }; return (
setTitle(e.target.value)} placeholder={t("titlePlaceholder")} maxLength={200} required />