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

This commit is contained in:
2026-07-09 17:59:54 +03:30
parent 1e6504771a
commit f61a1db4a7
4 changed files with 391 additions and 219 deletions
+76 -96
View File
@@ -1,114 +1,94 @@
import { type FC } from 'react' import { type FC } from 'react'
import { useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import moment from 'moment-jalaali'
import { ArrowRight2, Calendar } from 'iconsax-react'
import { Paths } from '@/config/Paths'
import { useGetRequestDetails } from './hooks/useRequestData' import { useGetRequestDetails } from './hooks/useRequestData'
import RequestDetailItem from './components/RequestDetailItem'
import TicketSection from './components/TicketSection' import TicketSection from './components/TicketSection'
import placeholderProductImage from '@/assets/images/placeholder-product.svg'
const RequestDetail: FC = () => { const RequestDetail: FC = () => {
const { id } = useParams() const { id } = useParams()
const { data } = useGetRequestDetails(id!) const { data, isPending, isError } = useGetRequestDetails(id!)
const request = data?.data
const items = request?.items ?? []
if (isPending && !request) {
return ( return (
<div className="w-full min-h-screen bg-[#eceef6] p-6"> <div className="mt-4 flex items-center justify-center min-h-[240px]">
{/* Header Section */} <span className="text-sm text-[#8C90A3]">در حال بارگذاری...</span>
<div className="flex items-start justify-between mb-6">
<div className="text-sm text-[#8C90A3]">
درخواست #{data?.data?.requestNumber}
</div>
</div>
{/* Order Information Section */}
<div className="bg-white rounded-2xl p-6 mb-6">
<div className='flex justify-between items-center'>
<h2 className="text-lg font-light mb-6">اطلاعات درخواست</h2>
</div>
<div className='flex flex-col gap-10'>
{
data?.data?.items?.map((item) => {
const productImage = item.product?.images?.[0]
return (
<div key={item.product.id}>
<div className="flex items-center gap-6">
{/* Product Image */}
<div className='size-[86px] rounded-lg overflow-hidden bg-gray-100'>
<img
src={productImage || placeholderProductImage}
alt={item.product?.title ?? 'محصول'}
className='size-full object-cover'
onError={(e) => {
e.currentTarget.src = placeholderProductImage
}}
/>
</div>
{/* Order Details */}
<div className="flex-1 flex gap-20 pr-10">
<div className='flex items-center gap-2'>
<div className="text-xs text-desc">عنوان:</div>
<div className="text-sm font-medium text-black">{item.product.title}</div>
</div>
</div>
</div>
{/* Order Description */}
<div className="mt-6 pt-6 border-t border-dashed border-desc">
<div className="text-sm font-medium mb-2 text-desc">شرح درخواست:</div>
<p className="text-sm font-light text-black leading-6">
{item.description || '—'}
</p>
</div>
{/* Attributes from field */}
{item.attributes?.length ? (
<>
<div className='font-bold mb-5 mt-7'>ویژگیها</div>
<div className='flex flex-col gap-3'>
{item.attributes.map((attr) => (
<div className='flex gap-3 items-center' key={attr.fieldId}>
<div className='text-sm text-gray-500'>{attr.field?.name}:</div>
<div className='text-sm -mt-px'>{attr.value ?? '—'}</div>
</div>
))}
</div>
</>
) : null}
{/* Attachments */}
{item.attachments?.length ? (
<div className="mt-6 pt-6 border-t border-dashed border-desc">
<div className="text-sm font-medium mb-3 text-desc">پیوستها:</div>
<div className="flex flex-wrap gap-4">
{item.attachments.map((att) => (
att.type === 'voice' ? (
<div key={att.url} className="flex items-center gap-2 p-3 bg-[#f5f6fa] rounded-xl">
<audio controls src={att.url} className="max-w-[240px] h-9" />
</div>
) : (
<a
key={att.url}
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="block size-20 rounded-lg overflow-hidden border border-desc hover:opacity-90"
>
<img src={att.url} alt="پیوست" className="size-full object-cover" />
</a>
)
))}
</div>
</div>
) : null}
</div> </div>
) )
})
} }
if (isError || !request) {
return (
<div className="mt-4 flex flex-col items-center justify-center min-h-[240px] gap-4">
<span className="text-sm text-[#8C90A3]">درخواست یافت نشد</span>
<Link
to={Paths.myRequests}
className="flex items-center gap-1 text-sm text-[#0047FF] hover:underline"
>
<ArrowRight2 size={18} color="#0047FF" />
بازگشت به لیست درخواستها
</Link>
</div>
)
}
return (
<div className="mt-4 pb-8">
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-lg font-light text-black">
درخواست #{request.requestNumber}
</h1>
<div className="flex items-center gap-2 mt-2 text-xs text-[#8C90A3]">
<Calendar size={14} />
<span>
تاریخ ثبت:{' '}
{moment(request.createdAt).format('jYYYY/jMM/jDD HH:mm')}
</span>
{items.length > 0 && (
<>
<span className="text-[#D8DCE8]">|</span>
<span>{items.length} قلم</span>
</>
)}
</div> </div>
</div> </div>
{/* Bottom Section - White Card */} <Link
to={Paths.myRequests}
className="flex items-center gap-1 text-sm text-[#8C90A3] hover:text-black transition-colors shrink-0"
>
<ArrowRight2 size={18} color="currentColor" />
بازگشت به لیست
</Link>
</div>
<section className="bg-white rounded-2xl p-6 md:p-8">
<h2 className="text-sm text-black mb-6">اطلاعات درخواست</h2>
{items.length === 0 ? (
<p className="text-sm text-[#8C90A3]">قلمی برای این درخواست ثبت نشده است.</p>
) : (
<div>
{items.map((item, index) => (
<RequestDetailItem
key={item.id}
item={item}
index={index}
total={items.length}
/>
))}
</div>
)}
</section>
<section className="mt-6">
<TicketSection /> <TicketSection />
</section>
</div> </div>
) )
} }
@@ -0,0 +1,134 @@
import { type FC } from 'react'
import { Paperclip2 } from 'iconsax-react'
import placeholderProductImage from '@/assets/images/placeholder-product.svg'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer'
import type { RequestDetailItemType } from '../type/Types'
type Props = {
item: RequestDetailItemType
index: number
total: number
}
const isImageUrl = (url: string) => /\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i.test(url)
const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
const productImage = item.product?.images?.[0]
const voiceAttachments = item.attachments?.filter((a) => a.type === 'voice') ?? []
const fileAttachments = item.attachments?.filter((a) => a.type !== 'voice') ?? []
const handleOpenLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
return (
<article className={index > 0 ? 'pt-8 mt-8 border-t border-[#EBEDF5]' : ''}>
<div className="flex items-start gap-5">
<div className="size-20 rounded-xl overflow-hidden bg-[#F5F7FC] shrink-0">
<img
src={productImage || placeholderProductImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
onError={(e) => {
e.currentTarget.src = placeholderProductImage
}}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-[#8C90A3]">عنوان:</span>
<span className="text-sm font-medium text-black">{item.product.title}</span>
</div>
{total > 1 && (
<span className="text-xs text-[#8C90A3] bg-[#F5F7FC] rounded-full px-2.5 py-0.5">
قلم {index + 1} از {total}
</span>
)}
</div>
<div className="mt-5 pt-5 border-t border-dashed border-[#D8DCE8]">
<div className="text-xs text-[#8C90A3] mb-2">شرح درخواست</div>
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
{item.description || '—'}
</p>
</div>
{item.attributes?.length > 0 && (
<div className="mt-5 pt-5 border-t border-dashed border-[#D8DCE8]">
<div className="text-xs text-[#8C90A3] mb-3">ویژگیها</div>
<div className="grid sm:grid-cols-2 gap-3">
{item.attributes.map((attr) => (
<div
key={attr.fieldId}
className="flex items-center gap-2 bg-[#F5F7FC] rounded-lg px-3 py-2"
>
<span className="text-xs text-[#8C90A3] shrink-0">
{attr.field?.name}:
</span>
<span className="text-sm text-black truncate">
{attr.value ?? '—'}
</span>
</div>
))}
</div>
</div>
)}
{fileAttachments.length > 0 && (
<div className="mt-5 pt-5 border-t border-dashed border-[#D8DCE8]">
<div className="text-xs text-[#8C90A3] mb-3 flex items-center gap-1.5">
<Paperclip2 size={16} />
پیوستها
</div>
<div className="flex flex-wrap gap-3">
{fileAttachments.map((att) =>
isImageUrl(att.url) ? (
<a
key={att.url}
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="block size-20 rounded-xl overflow-hidden border border-[#EBEDF5] hover:opacity-90 transition-opacity"
>
<img
src={att.url}
alt="پیوست"
className="size-full object-cover"
/>
</a>
) : (
<button
key={att.url}
type="button"
onClick={() => handleOpenLink(att.url)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[#F5F7FC] hover:bg-[#eceef6] transition-colors text-[#0047FF] text-xs"
>
<Paperclip2 size={16} color="#0047FF" />
<span>
{getFileNameAndExtensionFromUrl(att.url).fileName ||
'فایل ضمیمه'}
</span>
</button>
)
)}
</div>
</div>
)}
{voiceAttachments.length > 0 && (
<div className="mt-4 space-y-2">
{voiceAttachments.map((att) => (
<VoicePlayer key={att.url} url={att.url} />
))}
</div>
)}
</div>
</div>
</article>
)
}
export default RequestDetailItem
@@ -0,0 +1,86 @@
import { type FC } from 'react'
import moment from 'moment-jalaali'
import { Paperclip2 } from 'iconsax-react'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer'
import type { AttachmentsType } from '../type/Types'
type Props = {
content: string
attachments?: AttachmentsType[]
senderName?: string
senderLabel?: string
createdAt?: string
isAdmin?: boolean
}
const TicketMessage: FC<Props> = ({
content,
attachments = [],
senderName,
senderLabel = 'پشتیبان',
createdAt,
isAdmin = false,
}) => {
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
const handleOpenLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
return (
<div className={isAdmin ? 'flex justify-end' : ''}>
<div
className={`bg-[#F5F7FC] rounded-3xl p-5 max-w-[min(100%,520px)] ${
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
}`}
>
{isAdmin && senderName && (
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
<span className="font-medium text-black">{senderLabel}:</span>
<span>{senderName}</span>
</div>
)}
{content && (
<p className="text-sm text-black leading-7 whitespace-pre-wrap">{content}</p>
)}
{fileAttachments.length > 0 && (
<div className="flex gap-3 flex-wrap mt-3">
{fileAttachments.map((attach) => (
<button
key={attach.url}
type="button"
onClick={() => handleOpenLink(attach.url)}
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF] hover:opacity-80 transition-opacity"
>
<Paperclip2 size={18} color="#0047FF" />
<span className="text-xs">
{getFileNameAndExtensionFromUrl(attach.url).fileName}
</span>
</button>
))}
</div>
)}
{voiceAttachments.length > 0 && (
<div className="mt-3 space-y-2">
{voiceAttachments.map((voice) => (
<VoicePlayer key={voice.url} url={voice.url} />
))}
</div>
)}
{createdAt && (
<div className="mt-3 text-[11px] text-[#8C90A3] text-left" dir="ltr">
{moment(createdAt).format('jYYYY/jMM/jDD HH:mm')}
</div>
)}
</div>
</div>
)
}
export default TicketMessage
+92 -120
View File
@@ -1,26 +1,22 @@
import Button from '@/components/Button' import Button from '@/components/Button'
import UploadBox from '@/components/UploadBox' import UploadBox from '@/components/UploadBox'
import { Microphone, Paperclip2 } from 'iconsax-react' import { Microphone, Play, Pause } from 'iconsax-react'
import { useState, type FC } from 'react' import { useState, type FC } from 'react'
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder' import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
import { Play, Pause } from 'iconsax-react'
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader' import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
import type { AddTicketType } from '../type/Types' import type { AddTicketType } from '../type/Types'
import { useAddTicket, useGetTickets } from '../hooks/useRequestData' import { useAddTicket, useGetTickets } from '../hooks/useRequestData'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { toast } from '@/shared/toast' import { toast } from '@/shared/toast'
import { extractErrorMessage, getFileNameAndExtensionFromUrl } from '@/config/func' import { extractErrorMessage } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer' import TicketMessage from './TicketMessage'
const TicketSection: FC = () => { const TicketSection: FC = () => {
const { id } = useParams() const { id } = useParams()
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<File[]>([])
const addTicket = useAddTicket() const addTicket = useAddTicket()
const { data, refetch } = useGetTickets(id!) const { data, refetch, isPending: isLoadingTickets } = useGetTickets(id!)
const singleUpload = useSingleUpload() const singleUpload = useSingleUpload()
const multiUpload = useMultiUpload() const multiUpload = useMultiUpload()
@@ -34,38 +30,51 @@ const TicketSection: FC = () => {
stopRecording, stopRecording,
progress, progress,
audioFile, audioFile,
resetRecorder resetRecorder,
} = useVoiceRecorder() } = useVoiceRecorder()
const tickets = data?.data ?? []
const isSubmitting =
singleUpload.isPending || multiUpload.isPending || addTicket.isPending
const handleSubmit = async () => { const handleSubmit = async () => {
if (!message.trim() && !audioFile && files.length === 0) {
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
return
}
const params: AddTicketType = { const params: AddTicketType = {
attachments: [], attachments: [],
content: message content: message.trim(),
} }
if (audioFile) { if (audioFile) {
await singleUpload.mutateAsync(audioFile, { await singleUpload.mutateAsync(audioFile, {
onSuccess: (data) => { onSuccess: (data) => {
params.attachments.push({ params.attachments.push({
type: 'voice', type: 'voice',
url: data?.data?.url url: data?.data?.url,
}) })
} },
})
}
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.map((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url
})
})
}
}) })
} }
addTicket.mutate({ requestId: id!, params: params }, { if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.forEach((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url,
})
})
},
})
}
addTicket.mutate(
{ requestId: id!, params },
{
onSuccess: () => { onSuccess: () => {
toast('پیام شما با موفقیت ارسال شد') toast('پیام شما با موفقیت ارسال شد')
refetch() refetch()
@@ -76,110 +85,76 @@ const TicketSection: FC = () => {
onError: (error) => { onError: (error) => {
toast(extractErrorMessage(error), 'error') toast(extractErrorMessage(error), 'error')
}, },
})
} }
const handleOpenLink = (url: string) => {
window.open(
url,
'_blank'
) )
} }
return ( return (
<div className="bg-white rounded-2xl p-6"> <div className="bg-white rounded-2xl p-6 md:p-8">
<h2 className="text-sm text-black mb-2">گفتگو با پشتیبانی</h2>
<p className="text-xs text-[#8C90A3] mb-6">
سوالات یا توضیحات تکمیلی خود را با تیم پشتیبانی در میان بگذارید.
</p>
{ <div className="space-y-4 min-h-[80px]">
data?.data?.map((item) => { {isLoadingTickets && tickets.length === 0 && (
if (item.user) <p className="text-xs text-[#8C90A3]">در حال بارگذاری پیامها...</p>
)}
{!isLoadingTickets && tickets.length === 0 && (
<div className="rounded-xl bg-[#F5F7FC] px-4 py-6 text-center">
<p className="text-sm text-[#8C90A3]">هنوز پیامی ثبت نشده است.</p>
<p className="text-xs text-[#8C90A3] mt-1">
اولین پیام خود را در فرم زیر ارسال کنید.
</p>
</div>
)}
{tickets.map((item) => {
if (item.user) {
return ( return (
<div key={item.id} className='bg-[#F5F7FC] rounded-4xl rounded-tr-none mt-6 p-6 max-w-[55%]'> <TicketMessage
<div className='text-sm font-light text-black leading-6'> key={item.id}
{item.content} content={item.content}
</div> attachments={item.attachments}
createdAt={item.createdAt}
{/* attachments (non-voice) */} />
<div className="flex gap-3 flex-wrap mt-3">
{item.attachments
?.filter((a) => a.type !== 'voice')
.map((attach) => (
<div
key={attach.url}
onClick={() => handleOpenLink(attach.url)}
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]"
>
<Paperclip2 size={20} color="#0047FF" />
<div className="text-xs">
{getFileNameAndExtensionFromUrl(attach.url).fileName}
</div>
</div>
))}
</div>
{/* voice messages */}
{item.attachments
?.filter((a) => a.type === 'voice')
.map((voice) => (
<VoicePlayer key={voice.url} url={voice.url} />
))}
</div>
) )
else if (item.admin)
return (
<div className='flex justify-end'>
<div className='bg-[#F5F7FC] rounded-4xl rounded-tl-none mt-6 p-6 max-w-[55%]'>
<div className='flex gap-1 text-sm mb-2'>
<div className='font-bold'>پشتیبان : </div>
<div>{item.admin?.firstName + ' ' + item?.admin?.lastName}</div>
</div>
<div className='text-sm font-light text-black leading-6'>
{item.content}
</div>
{/* attachments (non-voice) */}
<div className="flex gap-3 flex-wrap mt-3">
{item.attachments
?.filter((a) => a.type !== 'voice')
.map((attach) => (
<div
key={attach.url}
onClick={() => handleOpenLink(attach.url)}
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]"
>
<Paperclip2 size={20} color="#0047FF" />
<div className="text-xs">
{getFileNameAndExtensionFromUrl(attach.url).fileName}
</div>
</div>
))}
</div>
{/* voice messages */}
{item.attachments
?.filter((a) => a.type === 'voice')
.map((voice) => (
<VoicePlayer key={voice.url} url={voice.url} />
))}
</div>
</div>
)
})
} }
<div className="mt-6"> if (item.admin) {
return (
<TicketMessage
key={item.id}
content={item.content}
attachments={item.attachments}
createdAt={item.createdAt}
isAdmin
senderName={`${item.admin.firstName} ${item.admin.lastName}`}
/>
)
}
return null
})}
</div>
<div className="mt-8 pt-6 border-t border-[#EBEDF5]">
<div className="text-sm mb-2 text-black">پیام شما</div> <div className="text-sm mb-2 text-black">پیام شما</div>
<div className="relative"> <div className="relative">
<textarea <textarea
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
className="w-full h-40 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none" className="w-full h-32 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none focus:border-[#0047FF] transition-colors"
placeholder="" placeholder="متن پیام خود را بنویسید..."
/> />
<button <button
type="button"
onClick={isRecording ? stopRecording : startRecording} onClick={isRecording ? stopRecording : startRecording}
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center" className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center hover:opacity-90 transition-opacity"
aria-label={isRecording ? 'توقف ضبط' : 'ضبط صدا'}
> >
<Microphone <Microphone
size={20} size={20}
@@ -192,8 +167,10 @@ const TicketSection: FC = () => {
{audioUrl && ( {audioUrl && (
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3"> <div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
<button <button
type="button"
onClick={togglePlayPause} onClick={togglePlayPause}
className="w-8 h-8 rounded-full bg-black flex items-center justify-center shrink-0" className="w-8 h-8 rounded-full bg-black flex items-center justify-center shrink-0"
aria-label={isPlaying ? 'توقف' : 'پخش'}
> >
{isPlaying ? ( {isPlaying ? (
<Pause size={16} color="#fff" variant="Bold" /> <Pause size={16} color="#fff" variant="Bold" />
@@ -211,7 +188,7 @@ const TicketSection: FC = () => {
className="w-[2px] rounded-full transition-all" className="w-[2px] rounded-full transition-all"
style={{ style={{
height: `${Math.random() * 18 + 4}px`, height: `${Math.random() * 18 + 4}px`,
backgroundColor: passed ? '#0047FF' : '#E5E7EB' backgroundColor: passed ? '#0047FF' : '#E5E7EB',
}} }}
/> />
) )
@@ -223,21 +200,16 @@ const TicketSection: FC = () => {
)} )}
</div> </div>
<div className="mt-6"> <div className="mt-6">
<UploadBox <UploadBox label="فایل‌های ضمیمه" isMultiple onChange={setFiles} />
label="فایل های ضمیمه"
isMultiple={true}
onChange={setFiles}
/>
</div> </div>
<div className="flex mt-10 justify-end"> <div className="flex mt-8 justify-end">
<Button <Button
label="ارسال" label="ارسال پیام"
onClick={handleSubmit} onClick={handleSubmit}
className='w-[150px]' className="w-[150px]"
isLoading={singleUpload.isPending || multiUpload.isPending || addTicket.isPending} isLoading={isSubmitting}
/> />
</div> </div>
</div> </div>