196 lines
5.6 KiB
TypeScript
196 lines
5.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|