diff --git a/app/(manage)/manage/suggestions/page.tsx b/app/(manage)/manage/suggestions/page.tsx new file mode 100644 index 0000000..cfd1c49 --- /dev/null +++ b/app/(manage)/manage/suggestions/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { SuggestionsClient } from "./suggestions-client" + +export const metadata: Metadata = { + title: "Suggestions", +} + +export default function SuggestionsPage() { + return +} diff --git a/app/(manage)/manage/suggestions/suggestions-client.tsx b/app/(manage)/manage/suggestions/suggestions-client.tsx new file mode 100644 index 0000000..fc356d1 --- /dev/null +++ b/app/(manage)/manage/suggestions/suggestions-client.tsx @@ -0,0 +1,373 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { cn } from "@/lib/utils" +import { ConfirmDialog } from "@/components/ui/modal" +import { + Loader2, + SearchIcon, + Lightbulb, + ExternalLinkIcon, + CheckCircle2Icon, + XCircleIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" + +interface Suggestion { + id: string + gameId: string + gameTitle: string + fieldName: string + currentValue: string | null + proposedValue: string + reason: string | null + status: string + createdAt: string + userName: string | null +} + +type ConfirmType = "approve" | "reject" + +const LIMIT = 50 + +function formatDate(value: string | Date | null | undefined) { + if (!value) return "—" + const d = typeof value === "string" ? new Date(value) : value + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +} + +export function SuggestionsClient() { + const [suggestions, setSuggestions] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [offset, setOffset] = useState(0) + const [actionLoading, setActionLoading] = useState>({}) + const [confirmAction, setConfirmAction] = useState<{ + type: ConfirmType + suggestion: Suggestion + } | null>(null) + const [activeTab, setActiveTab] = useState("all") + + const statusTabs = [ + { label: "All", value: "all" }, + { label: "Pending", value: "pending" }, + { label: "Approved", value: "approved" }, + { label: "Rejected", value: "rejected" }, + ] + + const fetchSuggestions = useCallback(async () => { + setLoading(true) + try { + const res = await fetch(`/api/community-suggestions/pending?limit=${LIMIT}`) + const json = await res.json() + const data = Array.isArray(json) ? json : (json.data ?? []) + setSuggestions(data) + setOffset(0) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchSuggestions() + }, [fetchSuggestions]) + + const filtered = useMemo(() => { + let result = suggestions + const term = search.trim().toLowerCase() + if (term) { + result = result.filter( + (s) => + s.gameTitle?.toLowerCase().includes(term) || + s.fieldName?.toLowerCase().includes(term) || + s.proposedValue?.toLowerCase().includes(term) || + s.userName?.toLowerCase().includes(term) || + s.reason?.toLowerCase().includes(term) + ) + } + if (activeTab !== "all") { + result = result.filter((s) => s.status === activeTab) + } + return result + }, [suggestions, search, activeTab]) + + const paginated = filtered.slice(offset, offset + LIMIT) + const total = filtered.length + const hasMore = offset + LIMIT < total + + const handlePrev = () => setOffset((prev) => Math.max(0, prev - LIMIT)) + const handleNext = () => setOffset((prev) => prev + LIMIT) + + const handleReview = async ( + suggestion: Suggestion, + status: "approved" | "rejected" + ) => { + setActionLoading((prev) => ({ ...prev, [suggestion.id]: true })) + try { + const res = await fetch( + `/api/community-suggestions/${suggestion.id}/review`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + } + ) + if (res.ok) { + setSuggestions((prev) => + prev.filter((s) => s.id !== suggestion.id) + ) + setConfirmAction(null) + } + } finally { + setActionLoading((prev) => ({ ...prev, [suggestion.id]: false })) + } + } + + return ( +
+ {/* Header */} +
+ +

Suggestions

+ ({filtered.length}) +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search suggestions..." + className="w-full pl-9 pr-4 py-2.5 rounded-lg bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors" + /> +
+ + {/* Status Tabs */} +
+ {statusTabs.map((tab) => ( + + ))} +
+ + {/* Suggestions List */} + {loading ? ( +
+ +
+ ) : paginated.length === 0 ? ( +
+ +

No suggestions found

+
+ ) : ( +
+ + + + + + + + + + + + + + + + {paginated.map((suggestion) => ( + + + + + + + + + + + + ))} + +
+ Game + + Field + + Current + + Proposed + + Reason + + By + + Date + + Status + + Actions +
+

+ {suggestion.gameTitle} +

+
+ + {suggestion.fieldName} + + +

+ {suggestion.currentValue || "—"} +

+
+

+ {suggestion.proposedValue} +

+
+

+ {suggestion.reason || "—"} +

+
+

+ {suggestion.userName || "Unknown"} +

+
+ + {formatDate(suggestion.createdAt)} + + + + + {suggestion.status} + + +
+ + + View + + + +
+
+
+ )} + + {/* Pagination */} + {total > 0 && ( +
+

+ Showing {offset + 1}–{Math.min(offset + paginated.length, total)} of{" "} + {total} +

+
+ + +
+
+ )} + + {/* Confirm Dialog */} + setConfirmAction(null)} + onConfirm={() => { + if (confirmAction) { + handleReview( + confirmAction.suggestion, + confirmAction.type === "approve" ? "approved" : "rejected" + ) + } + }} + title={`Confirm ${confirmAction?.type === "approve" ? "Approval" : "Rejection"}`} + message={ + confirmAction + ? `Are you sure you want to ${confirmAction.type === "approve" ? "approve" : "reject"} the "${confirmAction.suggestion.fieldName}" suggestion for "${confirmAction.suggestion.gameTitle}"?` + : "" + } + confirmLabel={confirmAction?.type === "approve" ? "Approve" : "Reject"} + cancelLabel="Cancel" + variant={confirmAction?.type === "approve" ? "default" : "destructive"} + loading={confirmAction ? !!actionLoading[confirmAction.suggestion.id] : false} + /> +
+ ) +}