canina/frontend/application/components/ProductReviews.tsx
parsa aghaei 3cbcad7b65
Some checks failed
E2E Playwright Tests / Run Full E2E & Security Suites (push) Waiting to run
Deploy Canina / deploy (push) Has been cancelled
feat(seo,ux,cro): enhance product page with JSON-LD @graph schemas, above-the-fold CRO highlights, PAO/storage badges, and dynamic reviews
2026-09-02 10:51:48 +03:30

384 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: unknown) {
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: unknown) {
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>
);
}