ifixkart-admin/app/(admin)/reviews-moderation/page.tsx

266 lines
12 KiB
TypeScript

'use client';
import { useMemo, useState, useEffect } from 'react';
import { Star, RefreshCw, Search, ShieldCheck, Image as ImageIcon } from 'lucide-react';
import { toast } from 'sonner';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
import { adminService } from '@/services/api/adminService';
interface ReviewItem {
review_id: string;
product_id: string;
author_name: string;
author_email?: string;
rating: number;
title: string;
comment: string;
verified_purchase: boolean;
is_approved: boolean;
review_date?: string;
images: string[];
}
type StatusFilter = 'all' | 'pending' | 'approved';
function formatDate(value?: string) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
}
export default function ReviewsModerationPage() {
const [reviews, setReviews] = useState<ReviewItem[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<StatusFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const fetchReviews = () => {
setLoading(true);
adminService.getCmsReviews()
.then((data) => { setReviews(data); setLoading(false); })
.catch(() => { toast.error('Failed to load reviews'); setLoading(false); });
};
useEffect(() => { fetchReviews(); }, []);
const pendingCount = reviews.filter((r) => !r.is_approved).length;
const approvedCount = reviews.filter((r) => r.is_approved).length;
const toggleApproval = async (review_id: string, current: boolean) => {
try {
if (current) {
await adminService.rejectReview(review_id);
setReviews((prev) => prev.map((r) => r.review_id === review_id ? { ...r, is_approved: false } : r));
toast.success('Review unpublished from the store');
} else {
await adminService.approveReview(review_id);
setReviews((prev) => prev.map((r) => r.review_id === review_id ? { ...r, is_approved: true } : r));
toast.success('Review approved and visible on the store');
}
} catch {
toast.error('Action failed — please try again');
}
};
const filtered = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return reviews.filter((r) => {
const statusMatch =
filter === 'all' ||
(filter === 'pending' && !r.is_approved) ||
(filter === 'approved' && r.is_approved);
const searchMatch =
!q ||
r.author_name.toLowerCase().includes(q) ||
r.title.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q);
return statusMatch && searchMatch;
});
}, [reviews, filter, searchQuery]);
const pager = useClientPagination(filtered);
const dataCardShell =
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0';
const renderStars = (rating: number) => (
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`w-3.5 h-3.5 ${i < rating ? 'text-warning fill-warning' : 'text-muted-foreground/30'}`}
/>
))}
</span>
);
return (
<div className="flex flex-col gap-5 min-w-0">
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Customer Reviews</h1>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
{reviews.length}
</span>
</div>
</div>
<button
type="button"
onClick={fetchReviews}
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
title="Reload"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
<div className={dataCardShell}>
<div className="px-5 pt-5">
<div className="-mx-5 px-5 flex flex-wrap items-center justify-between gap-3 min-w-0 pb-4 border-b border-border">
<div className="relative flex-1 min-w-[180px] max-w-[480px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1 shrink-0">
{([
{ id: 'all', label: `All (${reviews.length})` },
{ id: 'pending', label: `Pending (${pendingCount})` },
{ id: 'approved', label: `Approved (${approvedCount})` },
] as { id: StatusFilter; label: string }[]).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setFilter(tab.id)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
filter === tab.id ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading reviews...</span>
</div>
) : (
<div className="overflow-x-auto border-t border-border">
<table className="crm-data-table w-full table-fixed min-w-[980px]">
<colgroup>
<col className="w-[160px]" />
<col className="w-[200px]" />
<col className="w-[110px]" />
<col />
<col className="w-[100px]" />
<col className="w-[120px]" />
</colgroup>
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Customer</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Product</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Rating</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Review</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Action</th>
</tr>
</thead>
<tbody className="bg-card">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-[13px] text-muted-foreground whitespace-normal">
{searchQuery || filter !== 'all' ? 'No reviews match your search.' : 'No reviews found.'}
</td>
</tr>
) : (
pager.items.map((rev) => {
const photos = (rev.images || []).filter(Boolean);
return (
<tr key={rev.review_id} className="border-t border-border hover:bg-muted/10">
<td className="px-5 py-3.5 align-middle">
<p className="font-semibold text-foreground truncate" title={rev.author_name}>
{rev.author_name}
</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{formatDate(rev.review_date)}</p>
{rev.verified_purchase && (
<span className="mt-1.5 inline-flex items-center gap-1 px-2 py-0.5 crm-radius-badge text-[10px] font-semibold bg-success/10 text-success">
<ShieldCheck className="w-3 h-3" />
Verified
</span>
)}
</td>
<td className="px-5 py-3.5 align-middle text-muted-foreground">
<span className="block whitespace-normal break-words leading-snug text-[12px]">
{rev.product_id}
</span>
</td>
<td className="px-5 py-3.5 align-middle">
{renderStars(rev.rating)}
</td>
<td className="px-5 py-3.5 align-middle whitespace-normal">
<p className="font-semibold text-foreground leading-snug break-words">{rev.title}</p>
<p className="text-muted-foreground mt-1 leading-relaxed break-words">{rev.comment}</p>
{photos.length > 0 && (
<div className="flex gap-1.5 mt-2">
{photos.map((imgUrl, i) => (
<a key={i} href={imgUrl} target="_blank" rel="noopener noreferrer"
className="w-10 h-10 crm-radius-icon border border-border overflow-hidden bg-muted block">
<img src={imgUrl} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
{rev.images.length > 0 && photos.length === 0 && (
<span className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<ImageIcon className="w-3 h-3" />
Photo attached
</span>
)}
</td>
<td className="px-5 py-3.5 align-middle">
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${
rev.is_approved ? 'bg-success text-white' : 'bg-warning text-white'
}`}>
{rev.is_approved ? 'Approved' : 'Pending'}
</span>
</td>
<td className="px-5 py-3.5 align-middle">
<div className="flex justify-center">
<button
type="button"
onClick={() => toggleApproval(rev.review_id, rev.is_approved)}
className={`inline-flex items-center justify-center h-8 px-3 crm-radius-control text-[12px] font-semibold cursor-pointer border ${
rev.is_approved
? 'border-border bg-card text-foreground hover:bg-muted'
: 'border-primary bg-primary hover:bg-primary/90 text-white'
}`}
>
{rev.is_approved ? 'Unpublish' : 'Approve'}
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
<TablePagination {...pager} />
</div>
</div>
);
}