384 lines
16 KiB
TypeScript
384 lines
16 KiB
TypeScript
"use client";
|
||
import React, { useState, useEffect, useCallback } from "react";
|
||
import {
|
||
Star,
|
||
MessageSquare,
|
||
CheckCircle2,
|
||
Reply,
|
||
Loader2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Send,
|
||
User,
|
||
ShieldCheck,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import api from "../lib/services/api";
|
||
import { toPersian } from "../lib/utils";
|
||
|
||
interface ReviewItem {
|
||
id: string;
|
||
userName: string;
|
||
rating: number;
|
||
comment: string;
|
||
adminReply?: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface ProductReviewsProps {
|
||
productId: string;
|
||
productName: string;
|
||
}
|
||
|
||
export default function ProductReviews({ productId, productName }: ProductReviewsProps) {
|
||
const [reviews, setReviews] = useState<ReviewItem[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [stats, setStats] = useState({ averageRating: 5, totalReviews: 0 });
|
||
|
||
// Submission Form State
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [rating, setRating] = useState(5);
|
||
const [hoverRating, setHoverRating] = useState(0);
|
||
const [comment, setComment] = useState("");
|
||
const [userName, setUserName] = useState("");
|
||
const [userPhone, setUserPhone] = useState("");
|
||
const [formOpen, setFormOpen] = useState(false);
|
||
|
||
const fetchReviews = useCallback(async () => {
|
||
if (!productId) return;
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get(`/products/${productId}/reviews?page=${page}&limit=5`);
|
||
if (res.data?.success) {
|
||
setReviews(res.data.data || []);
|
||
setTotalPages(res.data.pagination?.totalPages || 1);
|
||
if (res.data.stats) {
|
||
setStats(res.data.stats);
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
console.error("Failed to fetch product reviews:", err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [productId, page]);
|
||
|
||
useEffect(() => {
|
||
fetchReviews();
|
||
}, [fetchReviews]);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!comment.trim()) {
|
||
toast.error("لطفاً متن نظر خود را وارد نمایید.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsSubmitting(true);
|
||
const res = await api.post(`/products/${productId}/reviews`, {
|
||
rating,
|
||
comment: comment.trim(),
|
||
userName: userName.trim() || undefined,
|
||
userPhone: userPhone.trim() || undefined,
|
||
});
|
||
|
||
if (res.data?.success) {
|
||
toast.success(
|
||
res.data.message || "دیدگاه شما با موفقیت ثبت شد و پس از تایید کارشناسان منتشر خواهد شد."
|
||
);
|
||
setComment("");
|
||
setFormOpen(false);
|
||
}
|
||
} catch (err: any) {
|
||
console.error("Failed to submit review:", err);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="bg-white border border-medical-gray-200 rounded-[2.5rem] p-6 sm:p-10 shadow-md space-y-8 font-vazir text-right" dir="rtl">
|
||
{/* Header & Rating Summary */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 pb-6 border-b border-medical-gray-100">
|
||
<div className="space-y-1 text-right">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-12 h-12 bg-canina-gold/10 text-canina-gold rounded-2xl flex items-center justify-center shadow-xs shrink-0">
|
||
<Star className="w-6 h-6 fill-canina-gold" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-xl sm:text-2xl font-black text-medical-gray-900">
|
||
نظرات و تجربیات خریداران
|
||
</h3>
|
||
<p className="text-xs text-medical-gray-400 font-bold mt-0.5">
|
||
دیدگاههای ثبتشده درباره اثرگذاری مکمل {productName}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rating Score Badge & Add Review Button */}
|
||
<div className="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
|
||
<div className="flex items-center gap-2.5 px-4 py-2.5 bg-medical-gray-50 border border-medical-gray-100 rounded-2xl">
|
||
<div className="flex items-center gap-0.5">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Star
|
||
key={i}
|
||
className={`w-3.5 h-3.5 ${
|
||
i < Math.round(stats.averageRating)
|
||
? "fill-amber-400 text-amber-400"
|
||
: "text-medical-gray-200"
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="text-xs font-black text-medical-gray-900">
|
||
{toPersian(stats.averageRating)} از ۵
|
||
</div>
|
||
<span className="text-[11px] text-medical-gray-400 font-bold border-r border-medical-gray-200 pr-2">
|
||
({toPersian(stats.totalReviews)} دیدگاه)
|
||
</span>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormOpen(!formOpen)}
|
||
className="px-5 py-2.5 bg-medical-gray-900 hover:bg-canina-blue text-white rounded-2xl text-xs font-black transition-all shadow-md shadow-black/10 flex items-center gap-2 cursor-pointer"
|
||
>
|
||
<MessageSquare className="w-4 h-4" />
|
||
<span>{formOpen ? "بستن فرم" : "ثبت دیدگاه جدید"}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Submission Form (Accordion / Collapsible) */}
|
||
{formOpen && (
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="bg-medical-gray-50/80 border border-medical-gray-200 rounded-3xl p-6 sm:p-8 space-y-5 animate-in fade-in duration-200"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-medical-gray-200 pb-3">
|
||
<h4 className="text-sm font-black text-medical-gray-900 flex items-center gap-2">
|
||
<Send className="w-4 h-4 text-canina-blue" />
|
||
ارسال نظر و امتیاز برای {productName}
|
||
</h4>
|
||
<span className="text-[11px] text-medical-gray-400 font-bold">
|
||
نظر شما پس از تایید مدیریت نمایش داده میشود
|
||
</span>
|
||
</div>
|
||
|
||
{/* Star Rating Selector */}
|
||
<div>
|
||
<label className="block text-xs font-black text-medical-gray-700 mb-2">
|
||
امتیاز شما به کیفیت و اثرگذاری محصول:
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
{Array.from({ length: 5 }).map((_, i) => {
|
||
const starVal = i + 1;
|
||
const active = starVal <= (hoverRating || rating);
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={i}
|
||
onMouseEnter={() => setHoverRating(starVal)}
|
||
onMouseLeave={() => setHoverRating(0)}
|
||
onClick={() => setRating(starVal)}
|
||
className="p-1 text-2xl transition-transform hover:scale-110"
|
||
>
|
||
<Star
|
||
className={`w-7 h-7 ${
|
||
active
|
||
? "fill-amber-400 text-amber-400"
|
||
: "text-medical-gray-300"
|
||
}`}
|
||
/>
|
||
</button>
|
||
);
|
||
})}
|
||
<span className="text-xs font-bold text-medical-gray-600 mr-2">
|
||
{toPersian(rating)} ستاره
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Name & Phone Inputs */}
|
||
<div className="grid sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-black text-medical-gray-700 mb-1.5">
|
||
نام و نام خانوادگی (اختیاری):
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={userName}
|
||
onChange={(e) => setUserName(e.target.value)}
|
||
placeholder="مثلاً علی محمدی"
|
||
className="w-full px-4 py-2.5 bg-white border border-medical-gray-200 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-black text-medical-gray-700 mb-1.5">
|
||
شماره موبایل (اختیاری - محرمانه):
|
||
</label>
|
||
<input
|
||
type="tel"
|
||
value={userPhone}
|
||
onChange={(e) => setUserPhone(e.target.value)}
|
||
placeholder="0912..."
|
||
className="w-full px-4 py-2.5 bg-white border border-medical-gray-200 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-canina-blue/20 font-mono"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Comment Textarea */}
|
||
<div>
|
||
<label className="block text-xs font-black text-medical-gray-700 mb-1.5">
|
||
متن دیدگاه یا تجربه مصرف: <span className="text-rose-500">*</span>
|
||
</label>
|
||
<textarea
|
||
required
|
||
rows={4}
|
||
value={comment}
|
||
onChange={(e) => setComment(e.target.value)}
|
||
placeholder="تجربه خود را از نحوه مصرف، تغییرات بالینی پت و اثربخشی این مکمل بنویسید..."
|
||
className="w-full p-4 bg-white border border-medical-gray-200 rounded-2xl text-xs sm:text-sm font-medium outline-none focus:ring-2 focus:ring-canina-blue/20 resize-none leading-relaxed"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormOpen(false)}
|
||
className="px-5 py-2.5 bg-medical-gray-200 hover:bg-medical-gray-300 text-medical-gray-700 rounded-xl text-xs font-bold transition-all"
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="px-6 py-2.5 bg-canina-blue hover:bg-canina-dark text-white rounded-xl text-xs font-black transition-all shadow-md flex items-center gap-2"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
)}
|
||
<span>ثبت و ارسال دیدگاه</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Reviews List */}
|
||
<div className="space-y-4">
|
||
{isLoading ? (
|
||
<div className="py-12 text-center text-medical-gray-400 font-bold text-xs flex flex-col items-center gap-2">
|
||
<Loader2 className="w-6 h-6 animate-spin text-canina-blue" />
|
||
<span>در حال دریافت نظرات خریداران...</span>
|
||
</div>
|
||
) : reviews.length === 0 ? (
|
||
<div className="py-12 text-center bg-medical-gray-50/50 border border-dashed border-medical-gray-200 rounded-3xl">
|
||
<p className="text-sm font-bold text-medical-gray-500">
|
||
هنوز دیدگاهی برای این محصول ثبت نشده است.
|
||
</p>
|
||
<p className="text-xs text-medical-gray-400 font-medium mt-1">
|
||
اولین نفری باشید که تجربه خود را درباره این محصول به اشتراک میگذارید.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
reviews.map((rev) => {
|
||
const date = new Date(rev.createdAt);
|
||
return (
|
||
<div
|
||
key={rev.id}
|
||
className="bg-medical-gray-50/60 border border-medical-gray-100 rounded-3xl p-5 sm:p-6 space-y-3 hover:bg-medical-gray-50 transition-colors"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="w-9 h-9 bg-canina-blue/10 text-canina-blue rounded-xl flex items-center justify-center font-black text-xs">
|
||
<User className="w-4 h-4" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs sm:text-sm font-black text-medical-gray-900 flex items-center gap-2">
|
||
<span>{rev.userName}</span>
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-md border border-emerald-200">
|
||
<ShieldCheck className="w-3 h-3" />
|
||
خریدار تایید شده
|
||
</span>
|
||
</div>
|
||
<div className="text-[10px] text-medical-gray-400 font-bold mt-0.5">
|
||
{toPersian(date.toLocaleDateString("fa-IR"))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stars */}
|
||
<div className="flex items-center gap-1">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Star
|
||
key={i}
|
||
className={`w-3.5 h-3.5 ${
|
||
i < rev.rating
|
||
? "fill-amber-400 text-amber-400"
|
||
: "text-medical-gray-200"
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Review Text */}
|
||
<p className="text-xs sm:text-sm text-medical-gray-700 font-medium leading-relaxed whitespace-pre-wrap pt-1">
|
||
{rev.comment}
|
||
</p>
|
||
|
||
{/* Admin Reply */}
|
||
{rev.adminReply && (
|
||
<div className="mt-3 p-3.5 bg-white border-r-3 border-canina-blue rounded-2xl text-xs space-y-1 shadow-xs">
|
||
<span className="font-black text-canina-blue flex items-center gap-1">
|
||
<Reply className="w-3.5 h-3.5 rotate-180" />
|
||
پاسخ کارشناس کنینا ایران:
|
||
</span>
|
||
<p className="text-medical-gray-600 font-medium leading-relaxed">
|
||
{rev.adminReply}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
|
||
{/* Pagination Controls */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between pt-4 border-t border-medical-gray-100">
|
||
<span className="text-xs font-bold text-medical-gray-400">
|
||
صفحه {toPersian(page)} از {toPersian(totalPages)}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
disabled={page <= 1 || isLoading}
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
className="px-3 py-1.5 border border-medical-gray-200 rounded-xl text-xs font-bold hover:bg-medical-gray-50 disabled:opacity-40 transition-all flex items-center gap-1"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
<span>قبلی</span>
|
||
</button>
|
||
<button
|
||
disabled={page >= totalPages || isLoading}
|
||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||
className="px-3 py-1.5 border border-medical-gray-200 rounded-xl text-xs font-bold hover:bg-medical-gray-50 disabled:opacity-40 transition-all flex items-center gap-1"
|
||
>
|
||
<span>بعدی</span>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|