feat: add feedback

This commit is contained in:
2026-06-07 15:41:33 +03:30
parent 2f976bade1
commit d3089ff2dd
2 changed files with 199 additions and 1 deletions
+195
View File
@@ -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>
);
}