266 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|