Vitron-Front/app/components/product/ReviewSection.tsx
2026-04-29 01:44:16 +03:30

414 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

import { Button } from "~/components/ui/button";
import { Review } from "./Review";
import { useState, useEffect, useRef } from "react";
import { ChevronLeft, ChevronRight, Pencil, X, Star } from "lucide-react";
import longArrow from "~/assets/icons/product/longArrow.svg";
import {
useServiceGetComments,
useServiceCreateComment,
useServiceSubmitRating,
} from "~/utils/RequestHandler";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "../ui/drawer";
import AppTextArea from "~/components/AppTextArea";
import { useRootData } from "~/hooks/use-root-data";
import { useToast } from "~/hooks/use-toast";
import { ProductComment } from "src/api/types";
import { formatDate } from "~/utils/helpers";
import { LoginRequiredDialog } from "../LoginRequiredDialog";
// Pagination component
const Pagination = ({
currentPage,
totalPages,
onPageChange,
}: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}) => {
return (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="p-2 hover:bg-WHITE3 disabled:opacity-50"
>
<ChevronRight size={24} />
</button>
{[...Array(totalPages)].map((_, index) => {
const pageNumber = index + 1;
// Show current page, first, last, and pages around current
if (
pageNumber === 1 ||
pageNumber === totalPages ||
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1)
) {
return (
<button
key={pageNumber}
onClick={() => onPageChange(pageNumber)}
className={`w-6 h-6 rounded-[5px] text-B12 font-bold flex items-center justify-center ${
currentPage === pageNumber
? "bg-BLACK text-WHITE"
: "hover:bg-WHITE3"
}`}
>
{pageNumber}
</button>
);
}
// Show ellipsis for gaps
if (
(pageNumber === 2 && currentPage > 3) ||
(pageNumber === totalPages - 1 && currentPage < totalPages - 2)
) {
return <span key={pageNumber}>...</span>;
}
return null;
})}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-2 hover:bg-WHITE3 disabled:opacity-50"
>
<ChevronLeft size={20} />
</button>
</div>
);
};
export const ReviewList = ({ productId }: { productId: string }) => {
const [page, setPage] = useState(1);
const perPage = 10; // Show 3 comments per page as requested
const { data, isLoading, error } = useServiceGetComments(page, productId);
if (isLoading)
return (
<div className="p-4 text-center min-h-[120px] flex items-center justify-center">
در حال بارگذاری نظرات...
</div>
);
if (error)
return (
<div className="p-4 text-center min-h-[120px] flex items-center justify-center text-red-500">
خطا در بارگذاری نظرات
</div>
);
if (!data || !data.results || data.results.length === 0)
return (
<div className="p-4 text-center min-h-[120px] flex items-center justify-center">
نظری ثبت نشده است
</div>
);
const totalPages = !data.count ? 1 : Math.ceil(data.count / perPage);
return (
<div className="flex px-4 gap-3 py-2 flex-col w-full">
{data.results?.map((comment: ProductComment, index: number) => (
<Review
isLast={!!data.results && index === data.results.length - 1}
key={comment.id}
id={comment.id}
productId={productId}
name={comment.user?.username || ""}
rating={parseInt(comment.userRating || "0")}
date={formatDate(comment.createdAt || "")}
comment={comment.comment || ""}
sellerReply={
comment.replies?.[0]
? {
date: formatDate(comment.replies[0].createdAt || ""),
comment: comment.replies[0].comment || "",
}
: undefined
}
/>
))}
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
};
export const ReviewSection = ({
productId,
isOwner = false,
}: {
productId: string;
isOwner?: boolean;
}) => {
// Get product ID from the URL if not provided
const resolvedProductId =
productId || window.location.pathname.split("/").pop() || "";
// Get total comment count using the same service
const { data } = useServiceGetComments(1, resolvedProductId);
const commentCount = data?.count || 0;
return (
<div className="w-full">
<div className="border-b w-full px-4">
<div className="flex py-6 w-full items-center justify-between">
<p className="text-B18 font-bold">
نظر خریداران {commentCount ? `(${commentCount})` : null}
</p>
{!isOwner && <SubmitReviewDrawer productId={resolvedProductId} />}
</div>
</div>
<ReviewList productId={resolvedProductId} />
</div>
);
};
const SubmitReviewDrawer = ({ productId }: { productId: string }) => {
const [submitReviewDrawer, setSubmitReviewDrawer] = useState(false);
const [rating, setRating] = useState<number | null>(null);
const [comment, setComment] = useState("");
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const textareaRef = useRef<HTMLDivElement>(null);
const drawerContentRef = useRef<HTMLDivElement>(null);
const [keyboardHeight, setKeyboardHeight] = useState(0);
// Get authentication token from localStorage
const { user } = useRootData();
const authToken = user?.access_token
? user.access_token.replace(/^"|"$/g, "")
: "";
// Get the mutations
const createCommentMutation = useServiceCreateComment();
const submitRatingMutation = useServiceSubmitRating();
// Handle keyboard open/close using visualViewport API
useEffect(() => {
if (!submitReviewDrawer) return;
const viewport = window.visualViewport;
if (!viewport) return;
const handleViewportResize = () => {
// Calculate keyboard height from the difference between window height and viewport height
const newKeyboardHeight = window.innerHeight - viewport.height;
setKeyboardHeight(Math.max(0, newKeyboardHeight));
// Scroll textarea into view when keyboard opens
if (newKeyboardHeight > 0 && textareaRef.current) {
setTimeout(() => {
textareaRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 100);
}
};
viewport.addEventListener("resize", handleViewportResize);
viewport.addEventListener("scroll", handleViewportResize);
return () => {
viewport.removeEventListener("resize", handleViewportResize);
viewport.removeEventListener("scroll", handleViewportResize);
};
}, [submitReviewDrawer]);
// Handle star rating click
const handleRatingClick = (newRating: number) => {
setRating(newRating);
};
// Handle star hover effects
const handleRatingHover = (hoveredRating: number | null) => {
setHoverRating(hoveredRating);
};
// Handle comment input change
const handleCommentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setComment(e.target.value);
};
// Handle form submission
const handleSubmit = async () => {
// Check if user is authenticated
if (!authToken) {
setError("لطفا ابتدا وارد حساب کاربری خود شوید.");
return;
}
// Form validation
if (!rating) {
setError("لطفا به این محصول امتیاز دهید.");
return;
}
if (!comment || comment.trim().length < 1) {
setError("لطفا نظر خود را درباره این محصول بنویسید.");
return;
}
setError(null);
setIsSubmitting(true);
try {
// Submit rating first
await submitRatingMutation.mutateAsync({
product_id: productId,
rating: rating,
});
// Then submit comment
await createCommentMutation.mutateAsync({
product: productId,
comment: comment,
parent: null,
});
// Reset form and close drawer on success
setComment("");
setRating(null);
setSubmitReviewDrawer(false);
// Show success toast
toast({
title: "نظر شما با موفقیت ثبت شد",
description: "نظر شما بعد از بررسی به زودی اضافه خواهد شد.",
});
} catch {
console.error("Error submitting review:");
setError("خطا در ثبت نظر. لطفا مجددا تلاش کنید.");
} finally {
setIsSubmitting(false);
}
};
// Determine the star fill based on rating and hover state
const getStarFill = (starPosition: number) => {
const currentRating = hoverRating !== null ? hoverRating : rating;
return currentRating !== null && starPosition <= currentRating
? "fill-current"
: "";
};
return (
<>
<LoginRequiredDialog
loginTitle="برای ثبت نظر و امتیاز ابتدا باید وارد حساب کاربری خود شوید."
isOpen={isLoginModalOpen}
onOpenChange={setIsLoginModalOpen}
/>
<Button
variant="primary"
size={"lg"}
className="font-bold"
onClick={() => {
if (!user?.access_token) {
setIsLoginModalOpen(true);
return;
}
setSubmitReviewDrawer(true);
}}
>
<Pencil size={24} />
ثبت نظر و امتیاز
</Button>
<Drawer open={submitReviewDrawer} onOpenChange={setSubmitReviewDrawer}>
<DrawerContent className="max-h-[95vh] flex flex-col">
<DrawerHeader className="flex flex-col py-3 shrink-0 sticky top-0 bg-WHITE z-10 border-b">
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
<X
className="absolute right-2 cursor-pointer"
size={24}
onClick={() => setSubmitReviewDrawer(false)}
/>
<DrawerTitle className="text-B12 font-bold">
ثبت نظر و امتیاز
</DrawerTitle>
</div>
</DrawerHeader>
<div
ref={drawerContentRef}
className="p-4 flex flex-col gap-6 overflow-y-auto overscroll-contain"
style={{ paddingBottom: keyboardHeight > 0 ? `${keyboardHeight + 16}px` : undefined }}
>
{/* Rating section */}
<div className="flex flex-col gap-0">
<p className="text-R14 text-right w-full">
به این محصول امتیاز دهید:
</p>
<div className="flex gap-2 mt-3 ltr">
{[1, 2, 3, 4, 5].map((starPosition) => (
<Star
key={starPosition}
size={48}
className={`cursor-pointer transition-all stroke-[1.5px] ${getStarFill(starPosition)}`}
onClick={() => handleRatingClick(starPosition)}
onMouseEnter={() => handleRatingHover(starPosition)}
onMouseLeave={() => handleRatingHover(null)}
/>
))}
</div>
<img
src={longArrow}
alt="longArrow"
className="w-[110px] h-auto"
/>
</div>
{/* Comment section - Using AppTextArea instead of AppInput */}
<div ref={textareaRef}>
<AppTextArea
inputTitle="نظر شما راجع به محصول:"
inputValue={comment}
inputOnChange={handleCommentChange}
inputPlaceholder="نظر خود راجع به این محصول را در این قسمت وارد کنید."
inputDir="rtl"
/>
</div>
{/* Error message */}
{error && (
<div className="text-red-500 text-R12 text-right">{error}</div>
)}
{/* Submit button */}
<div className="flex gap-3 mt-2">
<Button
variant="dark"
size="lg"
className="flex-1 fon text-[16px] t-bold"
onClick={handleSubmit}
disabled={isSubmitting}
>
<Pencil size={24} />
{isSubmitting ? "در حال ارسال..." : "ثبت نظر"}
</Button>
<Button
variant="outline"
size="lg"
className="flex-1 text-[16px] "
onClick={() => setSubmitReviewDrawer(false)}
>
انصراف
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
};