classify web

This commit is contained in:
Husanjonazamov
2026-02-24 12:52:49 +05:00
commit 64af77101f
310 changed files with 45449 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
"use client";
import { GoReport } from "react-icons/go";
import StarRating from "./StarRating";
import { formatDate, t } from "@/utils";
import { useState, useRef, useEffect } from "react";
import ReportReviewModal from "./ReportReviewModal";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import CustomImage from "@/components/Common/CustomImage";
const MyReviewsCard = ({ rating, setMyReviews }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isTextOverflowing, setIsTextOverflowing] = useState(false);
const textRef = useRef(null);
const [IsReportModalOpen, setIsReportModalOpen] = useState(false);
const [SellerReviewId, setSellerReviewId] = useState("");
useEffect(() => {
const checkTextOverflow = () => {
if (textRef.current) {
const isOverflowing =
textRef.current.scrollHeight > textRef.current.clientHeight;
setIsTextOverflowing(isOverflowing);
}
};
checkTextOverflow();
window.addEventListener("resize", checkTextOverflow);
return () => window.removeEventListener("resize", checkTextOverflow);
}, []);
const handleReportClick = (id) => {
setSellerReviewId(id);
setIsReportModalOpen(true);
};
return (
<div className="bg-white p-4 rounded-lg flex flex-col gap-4">
<div className="flex flex-col sm:flex-row flex-1 gap-4">
<div className="relative w-fit">
<CustomImage
src={rating?.buyer?.profile}
width={72}
height={72}
alt="Reviewer"
className="aspect-square rounded-full object-cover"
/>
<CustomImage
src={rating?.item?.image}
width={36}
height={36}
alt="Reviewer"
className="absolute top-12 bottom-[-6px] right-[-6px] w-[36px] h-auto aspect-square rounded-full object-cover"
/>
</div>
<div className="flex-1">
<div className="flex flex-1 items-center justify-between">
<p className="font-semibold">{rating?.buyer?.name}</p>
{rating?.report_status ? (
<div></div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button onClick={() => handleReportClick(rating?.id)}>
<GoReport />
</button>
</TooltipTrigger>
<TooltipContent side="left" align="center">
<p>{t("reportReview")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="mt-1 text-sm line-clamp-1">
{rating?.item?.translated_name || rating?.item?.name}
</p>
<div className="mt-1 flex items-center gap-1">
<StarRating rating={Number(rating?.ratings)} size={24} />
<span className="text-sm text-muted-foreground">
{rating?.ratings}
</span>
</div>
<p className="text-sm mt-1 justify-self-end">
{formatDate(rating?.created_at)}
</p>
</div>
</div>
<div className="border-b"></div>
<div>
<p ref={textRef} className={`${!isExpanded ? "line-clamp-2" : ""}`}>
{rating?.review}
</p>
{isTextOverflowing && (
<div className="flex justify-center mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-primary text-sm hover:underline"
>
{isExpanded ? "See Less" : "See More"}
</button>
</div>
)}
</div>
{/* Report Review Modal */}
<ReportReviewModal
isOpen={IsReportModalOpen}
setIsOpen={setIsReportModalOpen}
reviewId={SellerReviewId}
setMyReviews={setMyReviews}
/>
</div>
);
};
export default MyReviewsCard;

View File

@@ -0,0 +1,60 @@
import { Skeleton } from "@/components/ui/skeleton";
const ReviewCardItem = () => {
return (
<div className="bg-white p-4 rounded-lg flex flex-col gap-4">
<div className="flex flex-col sm:flex-row flex-1 gap-4">
{/* Profile image with smaller image overlay */}
<div className="relative w-fit">
<Skeleton className="w-[72px] h-[72px] rounded-full" />
<Skeleton className="w-[36px] h-[36px] rounded-full absolute top-12 bottom-[-6px] right-[-6px]" />
</div>
<div className="flex-1">
{/* Reviewer name and report icon */}
<div className="flex flex-1 items-center justify-between">
<Skeleton className="h-5 w-32 rounded-md" />
<Skeleton className="h-5 w-5 rounded-md" />
</div>
{/* Item name */}
<Skeleton className="h-4 w-48 mt-1 rounded-md" />
{/* Star rating */}
<div className="mt-1 flex items-center gap-1">
<Skeleton className="h-6 w-32 rounded-md" />
</div>
{/* Date */}
<Skeleton className="h-4 w-24 mt-1 rounded-md" />
</div>
</div>
{/* Divider */}
<div className="border-b"></div>
{/* Review text */}
<div>
<Skeleton className="h-4 w-full rounded-md mb-2" />
<Skeleton className="h-4 w-full rounded-md" />
{/* See More button (optional) */}
<div className="flex justify-center mt-1">
<Skeleton className="h-4 w-16 rounded-md" />
</div>
</div>
</div>
);
};
const MyReviewsCardSkeleton = () => {
return (
<div className="mt-[30px] p-2 sm:p-4 bg-muted rounded-xl flex flex-col gap-[30px]">
{Array(8).fill(0).map((_, index) => (
<ReviewCardItem key={`review-skeleton-${index}`} />
))}
</div>
);
};
export default MyReviewsCardSkeleton;

View File

@@ -0,0 +1,45 @@
'use client'
import StarRating from "./StarRating";
import { calculateRatingPercentages, t } from "@/utils";
import { TiStarFullOutline } from "react-icons/ti";
import { Progress } from "@/components/ui/progress";
const RatingsSummary = ({ averageRating, reviews }) => {
const { ratingCount, ratingPercentages } = reviews?.length
? calculateRatingPercentages(reviews)
: { ratingCount: {}, ratingPercentages: {} };
return (
<div className="grid grid-cols-1 md:grid-cols-12 p-4 rounded-xl border">
{/* Average Rating Section */}
<div className="col-span-4 border-b md:border-b-0 ltr:md:border-r rtl:md:border-l pb-4 md:pb-0 ltr:md:pr-4 rtl:md:pl-4">
<h1 className="font-bold text-6xl text-center">{Number(averageRating).toFixed(2)}</h1>
<div className="mt-4 flex flex-col items-center justify-center gap-1">
<StarRating rating={Number(averageRating)} size={40} />
<p className="text-sm">{reviews.length} {t('ratings')}</p>
</div>
</div>
{/* Rating Progress Bars Section */}
<div className="col-span-8 pt-4 md:pt-0 ltr:md:pl-8 rtl:md:pr-8">
<div className="flex flex-col gap-2">
{[5, 4, 3, 2, 1].map((rating) => (
<div className="flex items-center gap-2" key={`rating-${rating}`}>
<div className="flex items-center gap-1">
{rating}
<TiStarFullOutline color="black" size={24} />
</div>
<Progress value={ratingPercentages?.[rating] || 0} className='h-3' />
<span>{ratingCount?.[rating] || 0}</span>
</div>
))}
</div>
</div>
</div>
);
};
export default RatingsSummary;

View File

@@ -0,0 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
const RatingsSummarySkeleton = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-12 p-4 rounded-xl border">
{/* Average Rating Section Skeleton */}
<div className="col-span-4 border-b md:border-b-0 ltr:md:border-r pb-4 md:pb-0 md:pr-4">
<div className="flex flex-col items-center justify-center">
<Skeleton className="h-24 w-24 rounded-md mb-4" />
<Skeleton className="h-6 w-36 rounded-md mb-2" />
<Skeleton className="h-4 w-20 rounded-md" />
</div>
</div>
{/* Rating Progress Bars Section Skeleton */}
<div className="col-span-8 pt-4 md:pt-0 md:pl-8">
<div className="flex flex-col gap-3">
{[1, 2, 3, 4, 5].map((index) => (
<div className="flex items-center gap-2" key={`skeleton-rating-${index}`}>
<Skeleton className="h-6 w-10 rounded-md" />
<Skeleton className="h-3 flex-1 rounded-md" />
<Skeleton className="h-4 w-6 rounded-md" />
</div>
))}
</div>
</div>
</div>
);
};
export default RatingsSummarySkeleton;

View File

@@ -0,0 +1,109 @@
'use client';
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { addReportReviewApi } from "@/utils/api";
import { toast } from "sonner";
import { t } from "@/utils";
const ReportReviewModal = ({ isOpen, setIsOpen, reviewId, setMyReviews }) => {
const [reason, setReason] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [validationError, setValidationError] = useState("");
// Clear reason and validation error when modal closes
useEffect(() => {
if (!isOpen) {
setReason("");
setValidationError("");
}
}, [isOpen]);
const handleSubmit = async () => {
if (!reason.trim()) {
setValidationError("Please provide a reason for the report");
return;
}
setValidationError("");
setIsSubmitting(true);
try {
const res = await addReportReviewApi.addReportReview({
seller_review_id: reviewId,
report_reason: reason,
});
if (res?.data?.error === false) {
toast.success(res?.data?.message)
setMyReviews(prevReviews =>
prevReviews.map(review =>
review.id === reviewId ? { ...review, report_reason: res?.data?.data.report_reason, report_status: res?.data?.data.report_status } : review
)
);
setReason("");
setIsOpen(false);
} else {
toast.error(res?.data?.message)
}
} catch (error) {
console.error("Error reporting review:", error);
toast.error(t("somethingWentWrong"))
} finally {
setIsSubmitting(false);
}
};
// Clear validation error when user types
const handleReasonChange = (e) => {
setReason(e.target.value);
if (validationError) {
setValidationError("");
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent onInteractOutside={(e) => e.preventDefault()} className="px-6 py-6 sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-medium">
{t('reportReview')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-600 mt-2">
{t('reportReviewDescription')}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
<Textarea
placeholder="Write your reason here"
className={`h-32 resize-none ${validationError ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
value={reason}
onChange={handleReasonChange}
/>
{validationError && (
<p className="text-red-500 text-sm mt-1">{validationError}</p>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? t("submitting") : t("report")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default ReportReviewModal;

View File

@@ -0,0 +1,90 @@
"use client";
import { t } from "@/utils";
import RatingsSummary from "./RatingsSummary";
import RatingsSummarySkeleton from "./RatingsSummarySkeleton";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { getMyReviewsApi } from "@/utils/api";
import MyReviewsCard from "./MyReviewsCard.jsx";
import MyReviewsCardSkeleton from "@/components/PagesComponent/Reviews/MyReviewsCardSkeleton";
import { Button } from "@/components/ui/button";
import NoData from "@/components/EmptyStates/NoData";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
const Reviews = () => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const [MyReviews, setMyReviews] = useState([]);
const [AverageRating, setAverageRating] = useState("");
const [CurrentPage, setCurrentPage] = useState(1);
const [ReviewHasMore, setReviewHasMore] = useState(false);
const [IsLoading, setIsLoading] = useState(false);
const [IsLoadMore, setIsLoadMore] = useState(false);
const getReveiws = async (page) => {
try {
if (page === 1) {
setIsLoading(true);
}
const res = await getMyReviewsApi.getMyReviews({ page });
setAverageRating(res?.data?.data?.average_rating);
setMyReviews(res?.data?.data?.ratings?.data);
setCurrentPage(res?.data?.data?.ratings?.current_page);
if (
res?.data?.data?.ratings?.current_page <
res?.data?.data?.ratings?.last_page
) {
setReviewHasMore(true);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
setIsLoadMore(false);
}
};
useEffect(() => {
getReveiws(1);
}, [CurrentLanguage?.id]);
const handleReviewLoadMore = () => {
setIsLoadMore(true);
getReveiws(CurrentPage + 1);
};
return IsLoading ? (
<>
<RatingsSummarySkeleton />
<MyReviewsCardSkeleton />
</>
) : MyReviews && MyReviews.length > 0 ? (
<>
<RatingsSummary averageRating={AverageRating} reviews={MyReviews} />
<div className="mt-[30px] p-2 sm:p-4 bg-muted rounded-xl flex flex-col gap-[30px]">
{MyReviews?.map((rating) => (
<MyReviewsCard
rating={rating}
key={rating?.id}
setMyReviews={setMyReviews}
/>
))}
</div>
{ReviewHasMore && (
<div className="text-center mt-6">
<Button
variant="outline"
className="text-sm sm:text-base text-primary w-[256px]"
disabled={IsLoading || IsLoadMore}
onClick={handleReviewLoadMore}
>
{IsLoadMore ? t("loading") : t("loadMore")}
</Button>
</div>
)}
</>
) : (
<NoData name={t("reviews")} />
);
};
export default Reviews;

View File

@@ -0,0 +1,70 @@
import { formatDate, t } from "@/utils";
import { useEffect, useRef, useState } from "react";
import StarRating from "./StarRating";
import CustomImage from "@/components/Common/CustomImage";
const SellerReviewCard = ({ rating }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isTextOverflowing, setIsTextOverflowing] = useState(false);
const textRef = useRef(null);
useEffect(() => {
const checkOverflow = () => {
const content = textRef.current;
if (content) {
setIsTextOverflowing(content.scrollHeight > content.clientHeight);
}
};
checkOverflow();
window.addEventListener("resize", checkOverflow);
return () => window.removeEventListener("resize", checkOverflow);
}, []);
return (
<div className="bg-white p-4 rounded-lg flex flex-col gap-4">
<div className="flex sm:items-center gap-4 flex-col sm:flex-row">
<CustomImage
src={rating?.buyer?.profile}
width={72}
height={72}
alt="Reviewer"
className="aspect-square rounded-full object-cover"
/>
<div className="flex flex-col gap-1 w-full">
<p className="font-semibold">{rating?.buyer?.name}</p>
<div className="flex items-center justify-between flex-wrap gap-1 w-full">
<div className="flex items-center gap-1">
<StarRating rating={Number(rating?.ratings)} size={24} />
<span className="text-sm text-muted-foreground">
{rating?.ratings}
</span>
</div>
<p className="text-sm justify-self-end">
{formatDate(rating?.created_at)}
</p>
</div>
</div>
</div>
<div className="border-b"></div>
<div>
<p ref={textRef} className={`${!isExpanded ? "line-clamp-2" : ""}`}>
{rating?.review}
</p>
{isTextOverflowing && (
<div className="flex justify-center mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-primary text-sm hover:underline"
>
{isExpanded ? t("seeLess") : t("seeMore")}
</button>
</div>
)}
</div>
</div>
);
};
export default SellerReviewCard;

View File

@@ -0,0 +1,38 @@
'use client'
import { TiStarFullOutline, TiStarHalfOutline, TiStarOutline } from "react-icons/ti";
const StarRating = ({ rating = 0, size = 16, maxStars = 5, showEmpty = true }) => {
// Get the integer part of the rating (full stars)
const fullStars = Math.floor(rating);
// Check if there's any decimal part at all (0.1, 0.2, ..., 0.9)
const hasDecimal = rating % 1 !== 0;
// If there's any decimal, always show a half star
const hasHalfStar = hasDecimal;
// Calculate empty stars
const emptyStars = maxStars - fullStars - (hasHalfStar ? 1 : 0);
return (
<div className="flex items-center gap-1 max-w-full">
{/* Render full stars */}
{[...Array(fullStars)].map((_, index) => (
<TiStarFullOutline key={`full-${index}`} color="#FFD700" size={size} />
))}
{/* Render half star if there's any decimal */}
{hasHalfStar && (
<TiStarHalfOutline key="half" className="rtl:scale-x-[-1]" color="#FFD700" size={size} />
)}
{/* Render empty stars */}
{showEmpty && [...Array(emptyStars)].map((_, index) => (
<TiStarOutline key={`empty-${index}`} color="#0000002E" size={size} />
))}
</div>
);
};
export default StarRating;