feat: add feedback
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import { Button, Card, ConfirmDialog, PageHeader } from "@/components/ui";
|
||||
import { TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Feedback {
|
||||
id: number;
|
||||
stars?: number | null;
|
||||
content?: string | null;
|
||||
created_at?: string;
|
||||
user?: {
|
||||
name?: string | null;
|
||||
identifier?: string | null;
|
||||
mobile?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: number;
|
||||
rated: number;
|
||||
with_comment: number;
|
||||
average_stars: number;
|
||||
}
|
||||
|
||||
interface AdminResponse {
|
||||
summary: Summary;
|
||||
feedback: { data: Feedback[] };
|
||||
}
|
||||
|
||||
function Stars({ value }: { value?: number | null }) {
|
||||
if (!value) return <span className="text-muted">—</span>;
|
||||
return (
|
||||
<span className="text-amber-500" dir="ltr" title={toFa(value)}>
|
||||
{"★".repeat(value)}
|
||||
<span className="text-border">{"★".repeat(5 - value)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function userName(u: Feedback["user"]): string {
|
||||
return u?.name || u?.identifier || u?.mobile || "—";
|
||||
}
|
||||
|
||||
function faDate(value?: string): string {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return toFa(new Date(value).toLocaleDateString("fa-IR"));
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppFeedbackPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [rows, setRows] = useState<Feedback[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [deleting, setDeleting] = useState<Feedback | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
// Fetch without touching state synchronously (safe to call from an effect).
|
||||
const fetchFeedback = useCallback(() => {
|
||||
return apiFetch<AdminResponse>("/admin/app-feedback")
|
||||
.then((res) => {
|
||||
setRows(res.feedback?.data ?? []);
|
||||
setSummary(res.summary ?? null);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "خطا در دریافت اطلاعات"),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Reload triggered by user actions (retry / after delete): show the spinner.
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]);
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await apiFetch(`/admin/app-feedback/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("بازخورد حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Feedback>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
|
||||
{ key: "user", header: "کاربر", render: (r) => userName(r.user) },
|
||||
{
|
||||
key: "stars",
|
||||
header: "امتیاز",
|
||||
className: "w-28",
|
||||
render: (r) => <Stars value={r.stars} />,
|
||||
},
|
||||
{
|
||||
key: "content",
|
||||
header: "ایده / نظر",
|
||||
render: (r) => r.content || <span className="text-muted">—</span>,
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "تاریخ",
|
||||
className: "w-32",
|
||||
render: (r) => faDate(r.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="نظرات و ایدهها"
|
||||
subtitle="بازخورد کاربران درباره برنامه"
|
||||
/>
|
||||
|
||||
{summary && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<p className="text-sm text-muted">میانگین امتیاز</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||
{toFa(summary.average_stars)} <span className="text-amber-500">★</span>
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-muted">کل بازخوردها</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||
{toFa(summary.total)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-muted">دارای امتیاز</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||
{toFa(summary.rated)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-muted">دارای نظر</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||
{toFa(summary.with_comment)}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={reload}
|
||||
emptyMessage="هنوز بازخوردی ثبت نشده است."
|
||||
actions={(row) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeleting(row)}
|
||||
aria-label="حذف"
|
||||
className="text-danger"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف بازخورد «${userName(deleting?.user)}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -118,6 +118,9 @@ export const NAV: NavSection[] = [
|
||||
{
|
||||
label: "محتوای کاربران",
|
||||
icon: TagIcon,
|
||||
items: [{ href: "/dashboard/comments", label: "نظرات" }],
|
||||
items: [
|
||||
{ href: "/dashboard/comments", label: "نظرات" },
|
||||
{ href: "/dashboard/app-feedback", label: "نظرات و ایدهها برنامه" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user