order ticket
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
VITE_TOKEN_NAME = 'negareh_t'
|
VITE_TOKEN_NAME = 'negareh_t'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'negareh_rt'
|
VITE_REFRESH_TOKEN_NAME = 'negareh_rt'
|
||||||
VITE_API_BASE_URL = 'http://192.168.99.225:4000'
|
VITE_API_BASE_URL = 'http://10.24.161.1:4000'
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Pause, Play } from "iconsax-react"
|
||||||
|
import { useEffect, useRef, useState, type FC } from "react"
|
||||||
|
|
||||||
|
type VoicePlayerProps = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (!audio.duration) return
|
||||||
|
setProgress(audio.currentTime / audio.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', update)
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
setProgress(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', update)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!audioRef.current) return
|
||||||
|
if (isPlaying) audioRef.current.pause()
|
||||||
|
else audioRef.current.play()
|
||||||
|
setIsPlaying(!isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={16} color="#fff" variant="Bold" />
|
||||||
|
) : (
|
||||||
|
<Play size={16} color="#fff" variant="Bold" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center gap-[2px] h-8">
|
||||||
|
{Array.from({ length: 60 }).map((_, i) => {
|
||||||
|
const passed = i / 60 <= progress
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-[2px] rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${Math.random() * 18 + 4}px`,
|
||||||
|
backgroundColor: passed ? '#0047FF' : '#E5E7EB'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio ref={audioRef} src={url} className="hidden" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VoicePlayer
|
||||||
@@ -65,3 +65,24 @@ export const extractErrorMessage = (
|
|||||||
return fallbackMessage;
|
return fallbackMessage;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFileNameAndExtensionFromUrl = (
|
||||||
|
url: string,
|
||||||
|
): { fileName: string; extension: string } => {
|
||||||
|
try {
|
||||||
|
const cleanUrl = url.split("?")[0].split("#")[0];
|
||||||
|
const lastPart = cleanUrl.substring(cleanUrl.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
|
if (!lastPart || !lastPart.includes(".")) {
|
||||||
|
return { fileName: "", extension: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDotIndex = lastPart.lastIndexOf(".");
|
||||||
|
const fileName = lastPart.substring(0, lastDotIndex);
|
||||||
|
const extension = lastPart.substring(lastDotIndex + 1);
|
||||||
|
|
||||||
|
return { fileName, extension };
|
||||||
|
} catch {
|
||||||
|
return { fileName: "", extension: "" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
type UseVoiceRecorderOptions = {
|
||||||
|
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||||
|
const { onRecordingComplete } = options || {};
|
||||||
|
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mediaRecorder = new MediaRecorder(stream);
|
||||||
|
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||||
|
const file = new File([audioBlob], "voice-message.webm", {
|
||||||
|
type: "audio/webm",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
setAudioUrl(url);
|
||||||
|
setAudioFile(file);
|
||||||
|
onRecordingComplete?.(audioBlob);
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Microphone access error:", err);
|
||||||
|
}
|
||||||
|
}, [onRecordingComplete]);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
const resetRecorder = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunksRef.current = [];
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setAudioUrl(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
}, [audioUrl, isRecording]);
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying((prev) => !prev);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const updateProgress = useCallback(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updateProgress);
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
const onLoaded = () => setDuration(audio.duration);
|
||||||
|
const onEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("loadedmetadata", onLoaded);
|
||||||
|
audio.addEventListener("ended", onEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("loadedmetadata", onLoaded);
|
||||||
|
audio.removeEventListener("ended", onEnded);
|
||||||
|
};
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying) updateProgress();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, updateProgress]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
isRecording,
|
||||||
|
isPlaying,
|
||||||
|
audioUrl,
|
||||||
|
audioFile,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
|
||||||
|
// refs
|
||||||
|
audioRef,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
togglePlayPause,
|
||||||
|
resetRecorder,
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
progress: duration ? currentTime / duration : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { type FC, useState } from 'react'
|
import { type FC } from 'react'
|
||||||
import { Microphone, Paperclip2, Receipt1 } from 'iconsax-react'
|
import { Receipt1 } from 'iconsax-react'
|
||||||
import Button from '@/components/Button'
|
|
||||||
import UploadBox from '@/components/UploadBox'
|
|
||||||
import Input from '@/components/Input'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useGetOrderDetails } from './hooks/useOrderData'
|
import { useGetOrderDetails } from './hooks/useOrderData'
|
||||||
|
import TicketSection from './components/TicketSection'
|
||||||
|
|
||||||
const OrderDetail: FC = () => {
|
const OrderDetail: FC = () => {
|
||||||
|
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { data } = useGetOrderDetails(id!)
|
const { data } = useGetOrderDetails(id!)
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const [files, setFiles] = useState<File[]>([])
|
|
||||||
|
|
||||||
const orderData = {
|
const orderData = {
|
||||||
orderId: '۱۲۲۴۵',
|
orderId: '۱۲۲۴۵',
|
||||||
@@ -22,9 +19,6 @@ const OrderDetail: FC = () => {
|
|||||||
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد، تا با نرم افزارها شناخت بیشتری را برای طراحان رایانه ای علی الخصوص طراحان خلاق...'
|
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد، تا با نرم افزارها شناخت بیشتری را برای طراحان رایانه ای علی الخصوص طراحان خلاق...'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
console.log('Submit:', { message, files })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-screen bg-[#eceef6] p-6">
|
<div className="w-full min-h-screen bg-[#eceef6] p-6">
|
||||||
@@ -94,75 +88,7 @@ const OrderDetail: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Section - White Card */}
|
{/* Bottom Section - White Card */}
|
||||||
<div className="bg-white rounded-2xl p-6">
|
<TicketSection />
|
||||||
{/* Designer Name */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<Input
|
|
||||||
label="نام طراح"
|
|
||||||
placeholder="عباس حسینی"
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='bg-[#F5F7FC] rounded-4xl rounded-tr-none mt-6 p-6 max-w-[55%]'>
|
|
||||||
<div className='text-sm font-light text-black leading-6'>
|
|
||||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد،
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex justify-between mt-3'>
|
|
||||||
<div className='flex items-center gap-1.5 text-[#0047FF]'>
|
|
||||||
<Paperclip2 size={20} color="#0047FF" />
|
|
||||||
<div className='text-xs'>loremipsum.pdf</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>عباس حسینی</div>
|
|
||||||
</div>
|
|
||||||
<div className='text-sm font-light text-black leading-6'>
|
|
||||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد،
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="text-sm mb-2 text-black">پیام شما</div>
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
className="w-full h-40 bg-white border border-[#f5f7fc] rounded-xl p-4 text-sm resize-none outline-none"
|
|
||||||
placeholder=""
|
|
||||||
/>
|
|
||||||
<button className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center">
|
|
||||||
<Microphone size={20} color="black" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<UploadBox
|
|
||||||
label="فایل های ضمیمه"
|
|
||||||
isMultiple={true}
|
|
||||||
onChange={setFiles}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex mt-10 justify-end">
|
|
||||||
<Button
|
|
||||||
label="ارسال"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className='w-[150px]'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import UploadBox from '@/components/UploadBox'
|
||||||
|
import { Microphone, Paperclip2 } from 'iconsax-react'
|
||||||
|
import { useState, type FC } from 'react'
|
||||||
|
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||||
|
import { Play, Pause } from 'iconsax-react'
|
||||||
|
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||||
|
import type { AddTicketType } from '../type/Types'
|
||||||
|
import { useAddTicket, useGetTickets } from '../hooks/useOrderData'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { toast } from '@/shared/toast'
|
||||||
|
import { extractErrorMessage, getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||||
|
import VoicePlayer from '@/components/VoicePlayer'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const TicketSection: FC = () => {
|
||||||
|
|
||||||
|
const { id } = useParams()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const addTicket = useAddTicket()
|
||||||
|
const { data, refetch } = useGetTickets(id!)
|
||||||
|
const singleUpload = useSingleUpload()
|
||||||
|
const multiUpload = useMultiUpload()
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRecording,
|
||||||
|
isPlaying,
|
||||||
|
audioUrl,
|
||||||
|
audioRef,
|
||||||
|
togglePlayPause,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
progress,
|
||||||
|
audioFile,
|
||||||
|
resetRecorder
|
||||||
|
} = useVoiceRecorder()
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const params: AddTicketType = {
|
||||||
|
attachments: [],
|
||||||
|
content: message
|
||||||
|
}
|
||||||
|
if (audioFile) {
|
||||||
|
await singleUpload.mutateAsync(audioFile, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
params.attachments.push({
|
||||||
|
type: 'voice',
|
||||||
|
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({ orderId: id!, params: params }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast('پیام شما با موفقیت ارسال شد')
|
||||||
|
refetch()
|
||||||
|
setMessage('')
|
||||||
|
setFiles([])
|
||||||
|
resetRecorder()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast(extractErrorMessage(error), 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenLink = (url: string) => {
|
||||||
|
window.open(
|
||||||
|
url,
|
||||||
|
'_blank'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl p-6">
|
||||||
|
{/* Designer Name */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Input
|
||||||
|
label="نام طراح"
|
||||||
|
placeholder="عباس حسینی"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
data?.data?.map((item) => {
|
||||||
|
if (item.user)
|
||||||
|
return (
|
||||||
|
<div key={item.id} className='bg-[#F5F7FC] rounded-4xl rounded-tr-none mt-6 p-6 max-w-[55%]'>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
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">
|
||||||
|
<div className="text-sm mb-2 text-black">پیام شما</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
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"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Microphone
|
||||||
|
size={20}
|
||||||
|
color={isRecording ? '#EF4444' : 'black'}
|
||||||
|
variant={isRecording ? 'Bold' : 'Outline'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={16} color="#fff" variant="Bold" />
|
||||||
|
) : (
|
||||||
|
<Play size={16} color="#fff" variant="Bold" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center gap-[2px] h-8">
|
||||||
|
{Array.from({ length: 60 }).map((_, i) => {
|
||||||
|
const passed = i / 60 <= progress
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-[2px] rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${Math.random() * 18 + 4}px`,
|
||||||
|
backgroundColor: passed ? '#0047FF' : '#E5E7EB'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio ref={audioRef} src={audioUrl} className="hidden" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<UploadBox
|
||||||
|
label="فایل های ضمیمه"
|
||||||
|
isMultiple={true}
|
||||||
|
onChange={setFiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex mt-10 justify-end">
|
||||||
|
<Button
|
||||||
|
label="ارسال"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className='w-[150px]'
|
||||||
|
isLoading={singleUpload.isPending || multiUpload.isPending || addTicket.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TicketSection
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import * as api from "../service/OrderService";
|
import * as api from "../service/OrderService";
|
||||||
import type { TabMyOrdersEnum } from "../enum/OrderEnum";
|
import type { TabMyOrdersEnum } from "../enum/OrderEnum";
|
||||||
|
import type { AddTicketType } from "../type/Types";
|
||||||
|
|
||||||
export const useGetProducts = () => {
|
export const useGetProducts = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -29,3 +30,23 @@ export const useGetOrderDetails = (id: string) => {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetTickets = (orderId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["tickets", orderId],
|
||||||
|
queryFn: () => api.getTickets(orderId),
|
||||||
|
enabled: !!orderId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddTicket = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
orderId,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
params: AddTicketType;
|
||||||
|
}) => api.addTicket(orderId, params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import type {
|
import type {
|
||||||
|
AddTicketType,
|
||||||
MyOrdersResponseType,
|
MyOrdersResponseType,
|
||||||
OrderDetailResponseType,
|
OrderDetailResponseType,
|
||||||
OrderType,
|
OrderType,
|
||||||
ProductsResponseType,
|
ProductsResponseType,
|
||||||
|
TicketsResponseType,
|
||||||
} from "../type/Types";
|
} from "../type/Types";
|
||||||
import { OrderStatusEnum, TabMyOrdersEnum } from "../enum/OrderEnum";
|
import { OrderStatusEnum, TabMyOrdersEnum } from "../enum/OrderEnum";
|
||||||
|
|
||||||
@@ -56,3 +58,15 @@ export const getOrderDetails = async (id: string) => {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTickets = async (orderId: string) => {
|
||||||
|
const { data } = await axios.get<TicketsResponseType>(
|
||||||
|
`/public/ticket/order/${orderId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addTicket = async (orderId: string, params: AddTicketType) => {
|
||||||
|
const { data } = await axios.post(`/public/ticket/order/${orderId}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -99,3 +99,24 @@ export interface MyOrderType extends RowDataType {
|
|||||||
export type MyOrdersResponseType = BaseResponse<MyOrderType[]>;
|
export type MyOrdersResponseType = BaseResponse<MyOrderType[]>;
|
||||||
|
|
||||||
export type OrderDetailResponseType = BaseResponse<MyOrderType>;
|
export type OrderDetailResponseType = BaseResponse<MyOrderType>;
|
||||||
|
|
||||||
|
export type AddTicketType = {
|
||||||
|
content: string;
|
||||||
|
attachments: AttachmentsType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketType = {
|
||||||
|
admin?: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
attachments: AttachmentsType[];
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
id: number;
|
||||||
|
user?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketsResponseType = BaseResponse<TicketType[]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user