414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
};
|