+49
-278
@@ -1,22 +1,43 @@
|
|||||||
import { type FC, useState } from 'react'
|
import { type FC, useMemo, useState } from 'react'
|
||||||
import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData'
|
import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import PageLoading from '@/components/PageLoading'
|
import PageLoading from '@/components/PageLoading'
|
||||||
import Input from '@/components/Input'
|
import { toast } from 'react-toastify'
|
||||||
import Status from '@/components/Status'
|
import { extractErrorMessage } from '@/config/func'
|
||||||
import Select from '@/components/Select'
|
import ReviewStatusHeader from './components/ReviewStatusHeader'
|
||||||
import Button from '@/components/Button'
|
import ReviewChatView from './components/ReviewChatView'
|
||||||
import { Star1, User, ShoppingCart, DocumentText, Like1, Dislike } from 'iconsax-react'
|
import ReviewChatSidebar from './components/ReviewChatSidebar'
|
||||||
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
const getParentId = (parent: string | { id: string } | null | undefined) => {
|
||||||
|
if (!parent) return null
|
||||||
|
return typeof parent === 'string' ? parent : parent.id
|
||||||
|
}
|
||||||
|
|
||||||
const DetailReview: FC = () => {
|
const DetailReview: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation('global')
|
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { data: reviewData, isLoading } = useGetReviewById(id!)
|
const { data: reviewData, isLoading } = useGetReviewById(id!)
|
||||||
const updateStatusMutation = useUpdateReviewStatus()
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -26,8 +47,7 @@ const DetailReview: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const review = reviewData?.data
|
if (!review || !id) {
|
||||||
if (!review) {
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<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 = () => {
|
const handleStatusChange = () => {
|
||||||
if (selectedStatus && selectedStatus !== review.status && id) {
|
if (!selectedStatus || selectedStatus === review.status) return
|
||||||
|
|
||||||
updateStatusMutation.mutate(
|
updateStatusMutation.mutate(
|
||||||
{ id, status: selectedStatus },
|
{ id, status: selectedStatus },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelectedStatus('')
|
setSelectedStatus('')
|
||||||
|
toast.success('وضعیت نظر بهروزرسانی شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const userName = [review.user.firstName, review.user.lastName].filter(Boolean).join(' ') || '-'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center mb-8'>
|
<ReviewStatusHeader
|
||||||
<h1 className='text-lg font-light'>جزئیات نظر</h1>
|
status={review.status}
|
||||||
<div className='flex items-center gap-4'>
|
selectedStatus={selectedStatus}
|
||||||
<Status
|
onStatusChange={setSelectedStatus}
|
||||||
variant={getStatusVariant(review.status)}
|
onSave={handleStatusChange}
|
||||||
label={statusLabels[review.status] || review.status}
|
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'>
|
<div className='flex gap-6 items-start'>
|
||||||
{/* Main Content */}
|
<div className='flex-1 min-w-0'>
|
||||||
<div className='flex-1 bg-white rounded-4xl p-8 space-y-6'>
|
<ReviewChatView reviewId={id} review={review} replies={replies} />
|
||||||
{/* 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>
|
</div>
|
||||||
|
<ReviewChatSidebar review={review} />
|
||||||
</div>
|
</div>
|
||||||
</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 Status from '@/components/Status'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pages } from '@/config/Pages'
|
import { Pages } from '@/config/Pages'
|
||||||
|
import { getReviewStatusVariant, reviewStatusLabels } from '../utils/reviewStatus'
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getReviewTableColumns = (): ColumnType<Review>[] => {
|
export const getReviewTableColumns = (): ColumnType<Review>[] => {
|
||||||
return [
|
return [
|
||||||
@@ -21,7 +13,7 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
|
|||||||
key: 'user',
|
key: 'user',
|
||||||
title: 'کاربر',
|
title: 'کاربر',
|
||||||
render: (item: Review) => {
|
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 || '-'
|
return name || '-'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -63,15 +55,10 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
title: 'وضعیت',
|
title: 'وضعیت',
|
||||||
render: (item: Review) => {
|
render: (item: Review) => {
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
'pending': 'در انتظار',
|
|
||||||
'approved': 'تایید شده',
|
|
||||||
'rejected': 'رد شده',
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Status
|
<Status
|
||||||
variant={getStatusVariant(item.status)}
|
variant={getReviewStatusVariant(item.status)}
|
||||||
label={statusLabels[item.status] || item.status}
|
label={reviewStatusLabels[item.status] || item.status}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import type { GetReviewsResponse, GetReviewByIdResponse } from "../types/Types";
|
import type {
|
||||||
|
GetReviewsResponse,
|
||||||
|
GetReviewByIdResponse,
|
||||||
|
ReplyReviewResponse,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
export const getReviews = async (
|
export const getReviews = async (
|
||||||
params?: Record<string, string | number>
|
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 });
|
const { data } = await axios.patch(`/admin/reviews/${id}/status`, { status });
|
||||||
return data;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export interface ReviewOrder {
|
|||||||
paymentStatus: string;
|
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 {
|
export interface Review extends RowDataType {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -90,13 +100,18 @@ export interface ReviewDetail {
|
|||||||
isBuyer: boolean;
|
isBuyer: boolean;
|
||||||
order?: ReviewOrder;
|
order?: ReviewOrder;
|
||||||
food: ReviewFood;
|
food: ReviewFood;
|
||||||
user: ReviewUser;
|
user?: ReviewUser | null;
|
||||||
comment: string;
|
comment: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
positivePoints: string[];
|
positivePoints: string[];
|
||||||
negativePoints: string[];
|
negativePoints: string[];
|
||||||
status: string;
|
status: string;
|
||||||
|
parent?: string | { id: string } | null;
|
||||||
|
admin?: ReviewAdmin | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReviewReply = ReviewDetail;
|
||||||
|
|
||||||
export type GetReviewsResponse = IResponse<Review[]>;
|
export type GetReviewsResponse = IResponse<Review[]>;
|
||||||
export type GetReviewByIdResponse = IResponse<ReviewDetail>;
|
export type GetReviewByIdResponse = IResponse<ReviewDetail[]>;
|
||||||
|
export type ReplyReviewResponse = IResponse<ReviewReply>;
|
||||||
|
|||||||
@@ -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: "رد شده" },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user