review
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-17 10:18:55 +03:30
parent 7913c43bc0
commit 3e17cf9e12
13 changed files with 882 additions and 309 deletions
+49 -278
View File
@@ -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<string>('')
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 (
<div className='mt-5'>
<div className='flex justify-between items-center'>
@@ -38,287 +58,38 @@ const DetailReview: FC = () => {
)
}
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
'pending': 'pending',
'approved': 'success',
'rejected': 'error',
}
return variantMap[status] || 'pending'
}
const statusLabels: Record<string, string> = {
'pending': 'در انتظار',
'approved': 'تایید شده',
'rejected': 'رد شده',
}
const statusOptions = [
{ value: 'pending', label: 'در انتظار' },
{ value: 'approved', label: 'تایید شده' },
{ value: 'rejected', label: 'رد شده' },
]
const handleStatusChange = () => {
if (selectedStatus && selectedStatus !== review.status && id) {
if (!selectedStatus || selectedStatus === review.status) return
updateStatusMutation.mutate(
{ id, status: selectedStatus },
{
onSuccess: () => {
setSelectedStatus('')
toast.success('وضعیت نظر به‌روزرسانی شد')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
)
}
}
const userName = [review.user.firstName, review.user.lastName].filter(Boolean).join(' ') || '-'
return (
<div className='mt-5'>
<div className='flex justify-between items-center mb-8'>
<h1 className='text-lg font-light'>جزئیات نظر</h1>
<div className='flex items-center gap-4'>
<Status
variant={getStatusVariant(review.status)}
label={statusLabels[review.status] || review.status}
<ReviewStatusHeader
status={review.status}
selectedStatus={selectedStatus}
onStatusChange={setSelectedStatus}
onSave={handleStatusChange}
isSaving={updateStatusMutation.isPending}
/>
<div className='flex items-center gap-2'>
<div>
<Select
items={statusOptions}
value={selectedStatus || review.status}
onChange={(e) => setSelectedStatus(e.target.value)}
className='w-40'
/>
</div>
{selectedStatus && selectedStatus !== review.status && (
<Button
label='ذخیره'
onClick={handleStatusChange}
isloading={updateStatusMutation.isPending}
className='w-24'
/>
)}
</div>
</div>
</div>
<div className='flex gap-7'>
{/* Main Content */}
<div className='flex-1 bg-white rounded-4xl p-8 space-y-6'>
{/* Rating Section */}
<div className='flex items-center gap-3 pb-6 border-b border-border'>
<div className='flex items-center gap-2'>
<Star1 size={24} color="#FFB800" variant="Bold" />
<span className='text-2xl font-semibold'>{review.rating}</span>
</div>
<span className='text-sm text-gray-500'>از ۵</span>
</div>
{/* Comment Section */}
<div>
<div className='flex items-center gap-2 mb-4'>
<DocumentText size={20} color="#8C90A3" />
<span className='text-sm font-medium'>نظر</span>
</div>
<div className='bg-gray-50 rounded-xl p-4 border border-border'>
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
{review.comment || 'نظری ثبت نشده است'}
</p>
</div>
</div>
{/* Positive Points */}
{review.positivePoints && review.positivePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Like1 size={20} color="#22C55E" />
<span className='text-sm font-medium'>نقاط مثبت</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.positivePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-green-500 text-green-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
{/* Negative Points */}
{review.negativePoints && review.negativePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Dislike size={20} color="#EF4444" />
<span className='text-sm font-medium'>نقاط منفی</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.negativePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-red-500 text-red-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
{/* Food Information */}
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات غذا</span>
</div>
<div className='grid grid-cols-2 gap-4'>
<Input
label='عنوان غذا'
value={review.food.title}
readOnly
/>
<Input
label='قیمت'
value={formatPrice(review.food.price)}
readOnly
/>
<Input
label='امتیاز غذا'
value={formatFaNumber(review.food.score)}
readOnly
/>
<Input
label='دسته‌بندی'
value={review.food.category}
readOnly
/>
</div>
{review.food.images && review.food.images.length > 0 && (
<div className='mt-4'>
<span className='text-sm mb-2 block'>تصاویر غذا</span>
<div className='flex gap-3 flex-wrap'>
{review.food.images.map((image, index) => (
<img
key={index}
src={image}
alt={`غذا ${index + 1}`}
className='w-24 h-24 object-cover rounded-xl border border-border'
/>
))}
</div>
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className='w-[330px] h-fit bg-white rounded-4xl p-8 flex flex-col gap-6'>
{/* User Information */}
<div>
<div className='flex items-center gap-2 mb-4'>
<User size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات کاربر</span>
</div>
<div className='space-y-4'>
<Input
label='نام و نام خانوادگی'
value={userName}
readOnly
/>
<Input
label='شماره تماس'
value={review.user.phone}
readOnly
/>
<Input
label='تاریخ تولد'
value={formatOptionalDate(review.user.birthDate)}
readOnly
/>
<Input
label='خریدار'
value={review.isBuyer ? 'بله' : 'خیر'}
readOnly
/>
</div>
</div>
{review.order && (
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات سفارش</span>
</div>
<div className='space-y-4'>
<Input
label='شماره سفارش'
value={formatFaNumber(review.order.orderNumber)}
readOnly
/>
<Input
label='تاریخ سفارش'
value={formatOptionalDate(review.order.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
<Input
label='مبلغ کل'
value={formatPrice(review.order.total)}
readOnly
/>
<Input
label='وضعیت سفارش'
value={review.order.status}
readOnly
/>
<Input
label='وضعیت پرداخت'
value={review.order.paymentStatus}
readOnly
/>
</div>
</div>
)}
{/* Review Metadata */}
<div className='pt-6 border-t border-border'>
<div className='space-y-4'>
<Input
label='تاریخ ثبت نظر'
value={formatOptionalDate(review.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
{review.updatedAt && (
<Input
label='تاریخ آخرین بروزرسانی'
value={formatOptionalDate(review.updatedAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
)}
</div>
</div>
<div className='flex gap-6 items-start'>
<div className='flex-1 min-w-0'>
<ReviewChatView reviewId={id} review={review} replies={replies} />
</div>
<ReviewChatSidebar review={review} />
</div>
</div>
)
@@ -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<InfoRowProps> = ({ label, value }) => (
<div className='space-y-1'>
<span className='text-xs text-gray-500'>{label}</span>
<p className='text-sm text-gray-800 leading-6 break-words'>{value}</p>
</div>
)
const ReviewChatSidebar: FC<ReviewChatSidebarProps> = ({ review }) => {
const userName = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ') || '-'
return (
<div className='w-[280px] shrink-0 h-fit bg-white rounded-4xl p-6 flex flex-col gap-6'>
<div>
<div className='flex items-center gap-2 mb-4'>
<User size={18} color='#8C90A3' />
<span className='text-sm font-medium'>اطلاعات کاربر</span>
</div>
<div className='space-y-4'>
<InfoRow label='نام و نام خانوادگی' value={userName} />
<InfoRow label='شماره تماس' value={review.user?.phone || '-'} />
<InfoRow label='خریدار' value={review.isBuyer ? 'بله' : 'خیر'} />
</div>
</div>
<div className='pt-5 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={18} color='#8C90A3' />
<span className='text-sm font-medium'>اطلاعات غذا</span>
</div>
<div className='space-y-4'>
<InfoRow label='عنوان غذا' value={review.food?.title || '-'} />
<InfoRow label='قیمت' value={formatPrice(review.food?.price || 0)} />
<InfoRow label='امتیاز غذا' value={formatFaNumber(review.food?.score)} />
</div>
</div>
<div className='pt-5 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<Calendar size={18} color='#8C90A3' />
<span className='text-sm font-medium'>تاریخ ثبت نظر</span>
</div>
<p dir='ltr' className='text-sm text-gray-800 text-right'>
{formatOptionalDate(review.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
)
}
export default ReviewChatSidebar
@@ -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<ReviewChatViewProps> = ({ 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<HTMLFormElement>) => {
e.preventDefault()
handleSend()
}
return (
<div className='flex flex-col bg-white rounded-4xl overflow-hidden min-h-[520px] max-h-[calc(100vh-220px)]'>
<div className='px-6 py-4 border-b border-border shrink-0'>
<h2 className='text-base font-medium text-right'>{userName}</h2>
<p className='text-xs text-gray-500 mt-1 text-right'>{foodTitle}</p>
</div>
<div className='flex-1 overflow-y-auto px-6 py-5'>
<ul className='flex flex-col gap-5'>
{messages.map((message) => {
const isAdmin = message.type === 'admin'
return (
<li
key={message.id}
className={`flex w-full ${isAdmin ? 'justify-start' : 'justify-end'}`}
>
<div
className={`w-full max-w-[min(420px,85%)] rounded-[24px] px-5 py-4 ${
isAdmin
? 'bg-[#F6F7FA] rounded-tr-none'
: 'bg-[#EBEDF5] rounded-tl-none'
}`}
>
<div className='flex items-center justify-between gap-3 mb-2'>
<h6 className='text-xs font-medium text-gray-700'>
{message.senderName}
</h6>
{isAdmin && existingReply && (
<button
type='button'
onClick={handleDelete}
disabled={deleteMutation.isPending}
className='shrink-0 text-red-500 hover:text-red-600 disabled:opacity-50 p-1'
aria-label='حذف پاسخ'
>
<Trash size={16} />
</button>
)}
</div>
{!isAdmin && message.rating !== undefined && (
<div className='flex items-center gap-1.5 mb-2'>
<Star1 size={16} color='#FFB800' variant='Bold' />
<span className='text-sm font-medium'>{message.rating}</span>
<span className='text-xs text-gray-500'>از ۵</span>
</div>
)}
<p className='text-sm leading-7 text-gray-800 whitespace-pre-wrap break-words'>
{message.content}
</p>
{!isAdmin && message.positivePoints && message.positivePoints.length > 0 && (
<div className='flex flex-wrap gap-2 mt-3'>
{message.positivePoints.map((point, index) => (
<span
key={index}
className='px-3 py-1 border border-green-500 text-green-600 rounded-xl text-xs bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
)}
{!isAdmin && message.negativePoints && message.negativePoints.length > 0 && (
<div className='flex flex-wrap gap-2 mt-2'>
{message.negativePoints.map((point, index) => (
<span
key={index}
className='px-3 py-1 border border-red-500 text-red-600 rounded-xl text-xs bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
)}
{message.createdAt && (
<div className='mt-3 flex justify-end'>
<span dir='ltr' className='text-[11px] text-gray-400'>
{formatMessageTime(message.createdAt)}
</span>
</div>
)}
</div>
</li>
)
})}
</ul>
</div>
<div className='px-6 pb-6 pt-4 border-t border-border shrink-0'>
<form
onSubmit={handleSubmit}
className='focus-within:outline-blue-400 outline outline-transparent transition-colors duration-100 flex items-center gap-3 bg-white border border-border min-h-12 rounded-2xl py-2 px-3'
>
<input
value={comment}
onChange={(e) => 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
? 'برای ویرایش پاسخ، متن جدید بنویسید...'
: 'پاسخ خود را بنویسید...'
}
/>
<button
type='submit'
disabled={!canSend || replyMutation.isPending}
className='shrink-0 active:bg-neutral-100 transition-colors duration-150 rounded-full p-2 disabled:opacity-40'
aria-label='ارسال پاسخ'
>
<Send2
size={20}
color={canSend ? '#2563EB' : '#8C90A3'}
className='rotate-180'
/>
</button>
</form>
</div>
</div>
)
}
export default ReviewChatView
@@ -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<ReviewContentSectionProps> = ({ review }) => {
const { t } = useTranslation('global')
return (
<div className='space-y-6'>
<div className='flex items-center gap-3 pb-6 border-b border-border'>
<div className='flex items-center gap-2'>
<Star1 size={24} color="#FFB800" variant="Bold" />
<span className='text-2xl font-semibold'>{review.rating}</span>
</div>
<span className='text-sm text-gray-500'>از ۵</span>
</div>
<div>
<div className='flex items-center gap-2 mb-4'>
<DocumentText size={20} color="#8C90A3" />
<span className='text-sm font-medium'>نظر</span>
</div>
<div className='bg-gray-50 rounded-xl p-4 border border-border'>
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
{review.comment || 'نظری ثبت نشده است'}
</p>
</div>
</div>
{review.positivePoints && review.positivePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Like1 size={20} color="#22C55E" />
<span className='text-sm font-medium'>نقاط مثبت</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.positivePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-green-500 text-green-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
{review.negativePoints && review.negativePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Dislike size={20} color="#EF4444" />
<span className='text-sm font-medium'>نقاط منفی</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.negativePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-red-500 text-red-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
</div>
)
}
export default ReviewContentSection
@@ -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<ReviewFoodSectionProps> = ({ food }) => {
return (
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات غذا</span>
</div>
<div className='grid grid-cols-2 gap-4'>
<Input label='عنوان غذا' value={food.title} readOnly />
<Input label='قیمت' value={formatPrice(food.price)} readOnly />
<Input label='امتیاز غذا' value={formatFaNumber(food.score)} readOnly />
<Input label='دسته‌بندی' value={food.category} readOnly />
</div>
{food.images && food.images.length > 0 && (
<div className='mt-4'>
<span className='text-sm mb-2 block'>تصاویر غذا</span>
<div className='flex gap-3 flex-wrap'>
{food.images.map((image, index) => (
<img
key={index}
src={image}
alt={`غذا ${index + 1}`}
className='w-24 h-24 object-cover rounded-xl border border-border'
/>
))}
</div>
</div>
)}
</div>
)
}
export default ReviewFoodSection
@@ -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<ReviewReplySectionProps> = ({ 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 (
<div className='pt-6 border-t border-border space-y-6'>
<div className='flex items-center gap-2'>
<MessageText1 size={20} color="#8C90A3" />
<span className='text-sm font-medium'>پاسخ رستوران</span>
{replies.length > 0 && (
<span className='text-xs text-gray-400'>({replies.length})</span>
)}
</div>
{replies.length > 0 && (
<div className='space-y-3'>
{replies.map((reply) => (
<div
key={reply.id}
className='bg-gray-50 rounded-xl p-4 border border-border'
>
<div className='flex items-center justify-between mb-2'>
<span className='text-xs font-medium text-gray-500'>پاسخ</span>
{reply.createdAt && (
<span className='text-xs text-gray-500'>
{formatOptionalDate(reply.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
)}
</div>
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
{reply.comment || '-'}
</p>
</div>
))}
</div>
)}
<div>
<Textarea
label='متن پاسخ'
isNotRequired
placeholder='پاسخ خود را به این نظر بنویسید...'
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
/>
<div className='flex items-center gap-3 mt-4'>
<Button
label={existingReply ? 'ویرایش پاسخ' : 'ثبت پاسخ'}
onClick={handleSave}
isloading={replyMutation.isPending}
disabled={!canSave}
className='w-36'
/>
{existingReply && (
<Button
label='حذف پاسخ'
onClick={handleDelete}
isloading={deleteMutation.isPending}
className='w-28 bg-red-500 hover:bg-red-600'
/>
)}
</div>
</div>
</div>
)
}
export default ReviewReplySection
@@ -0,0 +1,107 @@
import { type FC } from 'react'
import Input from '@/components/Input'
import { User, ShoppingCart } from 'iconsax-react'
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
import type { ReviewDetail } from '../types/Types'
interface ReviewSidebarProps {
review: ReviewDetail
}
const ReviewSidebar: FC<ReviewSidebarProps> = ({ review }) => {
const userName = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ') || '-'
return (
<div className='w-[330px] h-fit bg-white rounded-4xl p-8 flex flex-col gap-6'>
<div>
<div className='flex items-center gap-2 mb-4'>
<User size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات کاربر</span>
</div>
<div className='space-y-4'>
<Input label='نام و نام خانوادگی' value={userName} readOnly />
<Input label='شماره تماس' value={review.user?.phone || '-'} readOnly />
<Input
label='تاریخ تولد'
value={formatOptionalDate(review.user?.birthDate)}
readOnly
/>
<Input
label='خریدار'
value={review.isBuyer ? 'بله' : 'خیر'}
readOnly
/>
</div>
</div>
{review.order && (
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات سفارش</span>
</div>
<div className='space-y-4'>
<Input
label='شماره سفارش'
value={formatFaNumber(review.order.orderNumber)}
readOnly
/>
<Input
label='تاریخ سفارش'
value={formatOptionalDate(review.order.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
<Input
label='مبلغ کل'
value={formatPrice(review.order.total)}
readOnly
/>
<Input label='وضعیت سفارش' value={review.order.status} readOnly />
<Input
label='وضعیت پرداخت'
value={review.order.paymentStatus}
readOnly
/>
</div>
</div>
)}
<div className='pt-6 border-t border-border'>
<div className='space-y-4'>
<Input
label='تاریخ ثبت نظر'
value={formatOptionalDate(review.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
{review.updatedAt && (
<Input
label='تاریخ آخرین بروزرسانی'
value={formatOptionalDate(review.updatedAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
)}
</div>
</div>
</div>
)
}
export default ReviewSidebar
@@ -0,0 +1,59 @@
import { type FC } from 'react'
import Status from '@/components/Status'
import Select from '@/components/Select'
import Button from '@/components/Button'
import {
getReviewStatusVariant,
reviewStatusLabels,
reviewStatusOptions,
} from '../utils/reviewStatus'
interface ReviewStatusHeaderProps {
status: string
selectedStatus: string
onStatusChange: (status: string) => void
onSave: () => void
isSaving: boolean
}
const ReviewStatusHeader: FC<ReviewStatusHeaderProps> = ({
status,
selectedStatus,
onStatusChange,
onSave,
isSaving,
}) => {
const hasChanges = Boolean(selectedStatus && selectedStatus !== status)
return (
<div className='flex justify-between items-center mb-8'>
<h1 className='text-lg font-light'>جزئیات نظر</h1>
<div className='flex items-center gap-4'>
<Status
variant={getReviewStatusVariant(status)}
label={reviewStatusLabels[status] || status}
/>
<div className='flex items-center gap-2'>
<div>
<Select
items={reviewStatusOptions}
value={selectedStatus || status}
onChange={(e) => onStatusChange(e.target.value)}
className='w-40'
/>
</div>
{hasChanges && (
<Button
label='ذخیره'
onClick={onSave}
isloading={isSaving}
className='w-24'
/>
)}
</div>
</div>
</div>
)
}
export default ReviewStatusHeader
@@ -5,15 +5,7 @@ import { formatOptionalDate } from '@/helpers/func'
import Status from '@/components/Status'
import { Link } from 'react-router-dom'
import { Pages } from '@/config/Pages'
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
'pending': 'pending',
'approved': 'success',
'rejected': 'error',
}
return variantMap[status] || 'pending'
}
import { getReviewStatusVariant, reviewStatusLabels } from '../utils/reviewStatus'
export const getReviewTableColumns = (): ColumnType<Review>[] => {
return [
@@ -21,7 +13,7 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
key: 'user',
title: 'کاربر',
render: (item: Review) => {
const name = [item.user.firstName, item.user.lastName].filter(Boolean).join(' ')
const name = [item.user?.firstName, item.user?.lastName].filter(Boolean).join(' ')
return name || '-'
}
},
@@ -63,15 +55,10 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
key: 'status',
title: 'وضعیت',
render: (item: Review) => {
const statusLabels: Record<string, string> = {
'pending': 'در انتظار',
'approved': 'تایید شده',
'rejected': 'رد شده',
}
return (
<Status
variant={getStatusVariant(item.status)}
label={statusLabels[item.status] || item.status}
variant={getReviewStatusVariant(item.status)}
label={reviewStatusLabels[item.status] || item.status}
/>
)
}
+23
View File
@@ -26,3 +26,26 @@ export const useUpdateReviewStatus = () => {
},
});
};
export const useReplyToReview = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, comment }: { id: string; comment: string }) =>
api.replyToReview(id, comment),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["reviews"] });
queryClient.invalidateQueries({ queryKey: ["review", variables.id] });
},
});
};
export const useDeleteReviewReply = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.deleteReviewReply(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ["reviews"] });
queryClient.invalidateQueries({ queryKey: ["review", id] });
},
});
};
+21 -1
View File
@@ -1,5 +1,9 @@
import axios from "@/config/axios";
import type { GetReviewsResponse, GetReviewByIdResponse } from "../types/Types";
import type {
GetReviewsResponse,
GetReviewByIdResponse,
ReplyReviewResponse,
} from "../types/Types";
export const getReviews = async (
params?: Record<string, string | number>
@@ -23,3 +27,19 @@ export const updateReviewStatus = async (id: string, status: string) => {
const { data } = await axios.patch(`/admin/reviews/${id}/status`, { status });
return data;
};
export const replyToReview = async (
id: string,
comment: string
): Promise<ReplyReviewResponse> => {
const { data } = await axios.post<ReplyReviewResponse>(
`/admin/reviews/${id}/reply`,
{ comment }
);
return data;
};
export const deleteReviewReply = async (id: string) => {
const { data } = await axios.delete(`/admin/reviews/${id}/reply`);
return data;
};
+17 -2
View File
@@ -67,6 +67,16 @@ export interface ReviewOrder {
paymentStatus: string;
}
export interface ReviewAdmin {
id: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
firstName?: string;
lastName?: string;
phone?: string;
}
export interface Review extends RowDataType {
id: string;
createdAt: string;
@@ -90,13 +100,18 @@ export interface ReviewDetail {
isBuyer: boolean;
order?: ReviewOrder;
food: ReviewFood;
user: ReviewUser;
user?: ReviewUser | null;
comment: string;
rating: number;
positivePoints: string[];
negativePoints: string[];
status: string;
parent?: string | { id: string } | null;
admin?: ReviewAdmin | null;
}
export type ReviewReply = ReviewDetail;
export type GetReviewsResponse = IResponse<Review[]>;
export type GetReviewByIdResponse = IResponse<ReviewDetail>;
export type GetReviewByIdResponse = IResponse<ReviewDetail[]>;
export type ReplyReviewResponse = IResponse<ReviewReply>;
+25
View File
@@ -0,0 +1,25 @@
export const getReviewStatusVariant = (
status: string
): "success" | "error" | "warning" | "info" | "pending" => {
const variantMap: Record<
string,
"success" | "error" | "warning" | "info" | "pending"
> = {
pending: "pending",
approved: "success",
rejected: "error",
};
return variantMap[status] || "pending";
};
export const reviewStatusLabels: Record<string, string> = {
pending: "در انتظار",
approved: "تایید شده",
rejected: "رد شده",
};
export const reviewStatusOptions = [
{ value: "pending", label: "در انتظار" },
{ value: "approved", label: "تایید شده" },
{ value: "rejected", label: "رد شده" },
];