From 3e17cf9e12c107c58805f7ce7612ec27ac896d4c Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Fri, 17 Jul 2026 10:18:55 +0330 Subject: [PATCH] review --- src/pages/review/Detail.tsx | 349 +++--------------- .../review/components/ReviewChatSidebar.tsx | 70 ++++ .../review/components/ReviewChatView.tsx | 249 +++++++++++++ .../components/ReviewContentSection.tsx | 76 ++++ .../review/components/ReviewFoodSection.tsx | 43 +++ .../review/components/ReviewReplySection.tsx | 128 +++++++ src/pages/review/components/ReviewSidebar.tsx | 107 ++++++ .../review/components/ReviewStatusHeader.tsx | 59 +++ .../review/components/ReviewTableColumns.tsx | 21 +- src/pages/review/hooks/useReviewData.ts | 23 ++ src/pages/review/service/ReviewService.ts | 22 +- src/pages/review/types/Types.ts | 19 +- src/pages/review/utils/reviewStatus.ts | 25 ++ 13 files changed, 882 insertions(+), 309 deletions(-) create mode 100644 src/pages/review/components/ReviewChatSidebar.tsx create mode 100644 src/pages/review/components/ReviewChatView.tsx create mode 100644 src/pages/review/components/ReviewContentSection.tsx create mode 100644 src/pages/review/components/ReviewFoodSection.tsx create mode 100644 src/pages/review/components/ReviewReplySection.tsx create mode 100644 src/pages/review/components/ReviewSidebar.tsx create mode 100644 src/pages/review/components/ReviewStatusHeader.tsx create mode 100644 src/pages/review/utils/reviewStatus.ts diff --git a/src/pages/review/Detail.tsx b/src/pages/review/Detail.tsx index 57426bf..14c4e62 100644 --- a/src/pages/review/Detail.tsx +++ b/src/pages/review/Detail.tsx @@ -1,22 +1,43 @@ -import { type FC, useState } from 'react' +import { type FC, useMemo, useState } from 'react' import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData' import { useParams } from 'react-router-dom' import PageLoading from '@/components/PageLoading' -import Input from '@/components/Input' -import Status from '@/components/Status' -import Select from '@/components/Select' -import Button from '@/components/Button' -import { Star1, User, ShoppingCart, DocumentText, Like1, Dislike } from 'iconsax-react' -import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func' -import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' +import { extractErrorMessage } from '@/config/func' +import ReviewStatusHeader from './components/ReviewStatusHeader' +import ReviewChatView from './components/ReviewChatView' +import ReviewChatSidebar from './components/ReviewChatSidebar' + +const getParentId = (parent: string | { id: string } | null | undefined) => { + if (!parent) return null + return typeof parent === 'string' ? parent : parent.id +} const DetailReview: FC = () => { - - const { t } = useTranslation('global') const { id } = useParams() const { data: reviewData, isLoading } = useGetReviewById(id!) const updateStatusMutation = useUpdateReviewStatus() - const [selectedStatus, setSelectedStatus] = useState('') + const [selectedStatus, setSelectedStatus] = useState('') + + const { review, replies } = useMemo(() => { + const items = reviewData?.data ?? [] + if (!id || items.length === 0) { + return { review: undefined, replies: [] } + } + + const parentReview = + items.find((item) => item.id === id) ?? + items.find((item) => !getParentId(item.parent)) + + const repliesList = items + .filter((item) => item.id !== parentReview?.id) + .sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ) + + return { review: parentReview, replies: repliesList } + }, [reviewData?.data, id]) if (isLoading) { return ( @@ -26,8 +47,7 @@ const DetailReview: FC = () => { ) } - const review = reviewData?.data - if (!review) { + if (!review || !id) { return (
@@ -38,290 +58,41 @@ const DetailReview: FC = () => { ) } - const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => { - const variantMap: Record = { - 'pending': 'pending', - 'approved': 'success', - 'rejected': 'error', - } - return variantMap[status] || 'pending' - } - - const statusLabels: Record = { - 'pending': 'در انتظار', - 'approved': 'تایید شده', - 'rejected': 'رد شده', - } - - const statusOptions = [ - { value: 'pending', label: 'در انتظار' }, - { value: 'approved', label: 'تایید شده' }, - { value: 'rejected', label: 'رد شده' }, - ] - const handleStatusChange = () => { - if (selectedStatus && selectedStatus !== review.status && id) { - updateStatusMutation.mutate( - { id, status: selectedStatus }, - { - onSuccess: () => { - setSelectedStatus('') - }, - } - ) - } - } + if (!selectedStatus || selectedStatus === review.status) return - const userName = [review.user.firstName, review.user.lastName].filter(Boolean).join(' ') || '-' + updateStatusMutation.mutate( + { id, status: selectedStatus }, + { + onSuccess: () => { + setSelectedStatus('') + toast.success('وضعیت نظر به‌روزرسانی شد') + }, + onError: (error) => { + toast.error(extractErrorMessage(error)) + }, + } + ) + } return (
-
-

جزئیات نظر

-
- -
-
- - - - -
- {review.food.images && review.food.images.length > 0 && ( -
- تصاویر غذا -
- {review.food.images.map((image, index) => ( - {`غذا - ))} -
-
- )} -
-
- - {/* Sidebar */} -
- {/* User Information */} -
-
- - اطلاعات کاربر -
-
- - - - -
-
- - {review.order && ( -
-
- - اطلاعات سفارش -
-
- - - - - -
-
- )} - - {/* Review Metadata */} -
-
- - {review.updatedAt && ( - - )} -
-
+ + +
+
+
+
) } -export default DetailReview \ No newline at end of file +export default DetailReview diff --git a/src/pages/review/components/ReviewChatSidebar.tsx b/src/pages/review/components/ReviewChatSidebar.tsx new file mode 100644 index 0000000..309c2dc --- /dev/null +++ b/src/pages/review/components/ReviewChatSidebar.tsx @@ -0,0 +1,70 @@ +import { type FC } from 'react' +import { User, ShoppingCart, Calendar } from 'iconsax-react' +import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func' +import type { ReviewDetail } from '../types/Types' + +interface ReviewChatSidebarProps { + review: ReviewDetail +} + +interface InfoRowProps { + label: string + value: string +} + +const InfoRow: FC = ({ label, value }) => ( +
+ {label} +

{value}

+
+) + +const ReviewChatSidebar: FC = ({ review }) => { + const userName = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ') || '-' + + return ( +
+
+
+ + اطلاعات کاربر +
+
+ + + +
+
+ +
+
+ + اطلاعات غذا +
+
+ + + +
+
+ +
+
+ + تاریخ ثبت نظر +
+

+ {formatOptionalDate(review.createdAt, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+ ) +} + +export default ReviewChatSidebar diff --git a/src/pages/review/components/ReviewChatView.tsx b/src/pages/review/components/ReviewChatView.tsx new file mode 100644 index 0000000..cbf6dac --- /dev/null +++ b/src/pages/review/components/ReviewChatView.tsx @@ -0,0 +1,249 @@ +import { type FC, useMemo, useState } from 'react' +import { Star1, Send2, Trash } from 'iconsax-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' +import { extractErrorMessage } from '@/config/func' +import { formatOptionalDate } from '@/helpers/func' +import { useDeleteReviewReply, useReplyToReview } from '../hooks/useReviewData' +import type { ReviewDetail, ReviewReply } from '../types/Types' + +interface ReviewChatViewProps { + reviewId: string + review: ReviewDetail + replies: ReviewReply[] +} + +const getDisplayName = ( + firstName?: string | null, + lastName?: string | null, + fallback = '-' +) => [firstName, lastName].filter(Boolean).join(' ') || fallback + +const formatMessageTime = (date?: string) => + formatOptionalDate(date, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + +const ReviewChatView: FC = ({ reviewId, review, replies }) => { + const { t } = useTranslation('global') + const existingReply = replies[0] + const [comment, setComment] = useState('') + const replyMutation = useReplyToReview() + const deleteMutation = useDeleteReviewReply() + + const userName = getDisplayName(review.user?.firstName, review.user?.lastName, 'کاربر') + const foodTitle = review.food?.title || '-' + + const messages = useMemo(() => { + const items: Array<{ + id: string + type: 'user' | 'admin' + senderName: string + content: string + createdAt?: string + rating?: number + positivePoints?: string[] + negativePoints?: string[] + }> = [ + { + id: review.id, + type: 'user', + senderName: userName, + content: review.comment || 'نظری ثبت نشده است', + createdAt: review.createdAt, + rating: review.rating, + positivePoints: review.positivePoints, + negativePoints: review.negativePoints, + }, + ] + + replies.forEach((reply) => { + items.push({ + id: reply.id, + type: 'admin', + senderName: getDisplayName( + reply.admin?.firstName, + reply.admin?.lastName, + 'رستوران' + ), + content: reply.comment || '-', + createdAt: reply.createdAt, + }) + }) + + return items + }, [review, replies, userName]) + + const trimmedComment = comment.trim() + const existingText = existingReply?.comment?.trim() || '' + const canSend = trimmedComment.length > 0 && trimmedComment !== existingText + + const handleSend = () => { + if (!canSend) return + + replyMutation.mutate( + { id: reviewId, comment: trimmedComment }, + { + onSuccess: () => { + setComment('') + toast.success(existingReply ? 'پاسخ با موفقیت به‌روزرسانی شد' : 'پاسخ با موفقیت ثبت شد') + }, + onError: (error) => { + toast.error(extractErrorMessage(error)) + }, + } + ) + } + + const handleDelete = () => { + deleteMutation.mutate(reviewId, { + onSuccess: () => { + setComment('') + toast.success('پاسخ حذف شد') + }, + onError: (error) => { + toast.error(extractErrorMessage(error)) + }, + }) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + handleSend() + } + + return ( +
+
+

{userName}

+

{foodTitle}

+
+ +
+
    + {messages.map((message) => { + const isAdmin = message.type === 'admin' + + return ( +
  • +
    +
    +
    + {message.senderName} +
    + {isAdmin && existingReply && ( + + )} +
    + + {!isAdmin && message.rating !== undefined && ( +
    + + {message.rating} + از ۵ +
    + )} + +

    + {message.content} +

    + + {!isAdmin && message.positivePoints && message.positivePoints.length > 0 && ( +
    + {message.positivePoints.map((point, index) => ( + + {t(`reviews.${point}`)} + + ))} +
    + )} + + {!isAdmin && message.negativePoints && message.negativePoints.length > 0 && ( +
    + {message.negativePoints.map((point, index) => ( + + {t(`reviews.${point}`)} + + ))} +
    + )} + + {message.createdAt && ( +
    + + {formatMessageTime(message.createdAt)} + +
    + )} +
    +
  • + ) + })} +
+
+ +
+
+ + setComment(e.target.value)} + autoComplete='off' + dir='rtl' + className='flex-1 min-w-0 text-sm text-right bg-transparent outline-none placeholder:text-gray-400' + type='text' + placeholder={ + existingReply + ? 'برای ویرایش پاسخ، متن جدید بنویسید...' + : 'پاسخ خود را بنویسید...' + } + /> + +
+
+
+ ) +} + +export default ReviewChatView diff --git a/src/pages/review/components/ReviewContentSection.tsx b/src/pages/review/components/ReviewContentSection.tsx new file mode 100644 index 0000000..034ff20 --- /dev/null +++ b/src/pages/review/components/ReviewContentSection.tsx @@ -0,0 +1,76 @@ +import { type FC } from 'react' +import { Star1, DocumentText, Like1, Dislike } from 'iconsax-react' +import { useTranslation } from 'react-i18next' +import type { ReviewDetail } from '../types/Types' + +interface ReviewContentSectionProps { + review: ReviewDetail +} + +const ReviewContentSection: FC = ({ review }) => { + const { t } = useTranslation('global') + + return ( +
+
+
+ + {review.rating} +
+ از ۵ +
+ +
+
+ + نظر +
+
+

+ {review.comment || 'نظری ثبت نشده است'} +

+
+
+ + {review.positivePoints && review.positivePoints.length > 0 && ( +
+
+ + نقاط مثبت +
+
+ {review.positivePoints.map((point, index) => ( + + {t(`reviews.${point}`)} + + ))} +
+
+ )} + + {review.negativePoints && review.negativePoints.length > 0 && ( +
+
+ + نقاط منفی +
+
+ {review.negativePoints.map((point, index) => ( + + {t(`reviews.${point}`)} + + ))} +
+
+ )} +
+ ) +} + +export default ReviewContentSection diff --git a/src/pages/review/components/ReviewFoodSection.tsx b/src/pages/review/components/ReviewFoodSection.tsx new file mode 100644 index 0000000..ea52be3 --- /dev/null +++ b/src/pages/review/components/ReviewFoodSection.tsx @@ -0,0 +1,43 @@ +import { type FC } from 'react' +import Input from '@/components/Input' +import { ShoppingCart } from 'iconsax-react' +import { formatPrice, formatFaNumber } from '@/helpers/func' +import type { ReviewFood } from '../types/Types' + +interface ReviewFoodSectionProps { + food: ReviewFood +} + +const ReviewFoodSection: FC = ({ food }) => { + return ( +
+
+ + اطلاعات غذا +
+
+ + + + +
+ {food.images && food.images.length > 0 && ( +
+ تصاویر غذا +
+ {food.images.map((image, index) => ( + {`غذا + ))} +
+
+ )} +
+ ) +} + +export default ReviewFoodSection diff --git a/src/pages/review/components/ReviewReplySection.tsx b/src/pages/review/components/ReviewReplySection.tsx new file mode 100644 index 0000000..cdd4392 --- /dev/null +++ b/src/pages/review/components/ReviewReplySection.tsx @@ -0,0 +1,128 @@ +import { type FC, useEffect, useState } from 'react' +import { MessageText1 } from 'iconsax-react' +import Textarea from '@/components/Textarea' +import Button from '@/components/Button' +import { formatOptionalDate } from '@/helpers/func' +import { toast } from 'react-toastify' +import { extractErrorMessage } from '@/config/func' +import { useDeleteReviewReply, useReplyToReview } from '../hooks/useReviewData' +import type { ReviewReply } from '../types/Types' + +interface ReviewReplySectionProps { + reviewId: string + replies: ReviewReply[] +} + +const ReviewReplySection: FC = ({ reviewId, replies }) => { + const existingReply = replies[0] + const [comment, setComment] = useState(existingReply?.comment || '') + const replyMutation = useReplyToReview() + const deleteMutation = useDeleteReviewReply() + + useEffect(() => { + setComment(existingReply?.comment || '') + }, [existingReply?.comment, existingReply?.id]) + + const hasChanges = comment.trim() !== (existingReply?.comment || '').trim() + const canSave = comment.trim().length > 0 && hasChanges + + const handleSave = () => { + if (!canSave) return + + replyMutation.mutate( + { id: reviewId, comment: comment.trim() }, + { + onSuccess: () => { + toast.success(existingReply ? 'پاسخ با موفقیت به‌روزرسانی شد' : 'پاسخ با موفقیت ثبت شد') + }, + onError: (error) => { + toast.error(extractErrorMessage(error)) + }, + } + ) + } + + const handleDelete = () => { + deleteMutation.mutate(reviewId, { + onSuccess: () => { + setComment('') + toast.success('پاسخ حذف شد') + }, + onError: (error) => { + toast.error(extractErrorMessage(error)) + }, + }) + } + + return ( +
+
+ + پاسخ رستوران + {replies.length > 0 && ( + ({replies.length}) + )} +
+ + {replies.length > 0 && ( +
+ {replies.map((reply) => ( +
+
+ پاسخ + {reply.createdAt && ( + + {formatOptionalDate(reply.createdAt, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + + )} +
+

+ {reply.comment || '-'} +

+
+ ))} +
+ )} + +
+