order ticket

This commit is contained in:
hamid zarghami
2026-01-28 16:01:00 +03:30
parent a5ea0174ee
commit 10245bdb08
9 changed files with 577 additions and 80 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
VITE_TOKEN_NAME = 'negareh_t'
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'
+74
View File
@@ -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
+21
View File
@@ -65,3 +65,24 @@ export const extractErrorMessage = (
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: "" };
}
};
+159
View File
@@ -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,
};
};
+5 -79
View File
@@ -1,17 +1,14 @@
import { type FC, useState } from 'react'
import { Microphone, Paperclip2, Receipt1 } from 'iconsax-react'
import Button from '@/components/Button'
import UploadBox from '@/components/UploadBox'
import Input from '@/components/Input'
import { type FC } from 'react'
import { Receipt1 } from 'iconsax-react'
import { useParams } from 'react-router-dom'
import { useGetOrderDetails } from './hooks/useOrderData'
import TicketSection from './components/TicketSection'
const OrderDetail: FC = () => {
const { id } = useParams()
const { data } = useGetOrderDetails(id!)
const [message, setMessage] = useState('')
const [files, setFiles] = useState<File[]>([])
const orderData = {
orderId: '۱۲۲۴۵',
@@ -22,9 +19,6 @@ const OrderDetail: FC = () => {
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد، تا با نرم افزارها شناخت بیشتری را برای طراحان رایانه ای علی الخصوص طراحان خلاق...'
}
const handleSubmit = () => {
console.log('Submit:', { message, files })
}
return (
<div className="w-full min-h-screen bg-[#eceef6] p-6">
@@ -94,75 +88,7 @@ const OrderDetail: FC = () => {
</div>
{/* Bottom Section - White Card */}
<div className="bg-white rounded-2xl p-6">
{/* 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>
<TicketSection />
</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
+21
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/OrderService";
import type { TabMyOrdersEnum } from "../enum/OrderEnum";
import type { AddTicketType } from "../type/Types";
export const useGetProducts = () => {
return useQuery({
@@ -29,3 +30,23 @@ export const useGetOrderDetails = (id: string) => {
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),
});
};
+14
View File
@@ -1,9 +1,11 @@
import axios from "@/config/axios";
import type {
AddTicketType,
MyOrdersResponseType,
OrderDetailResponseType,
OrderType,
ProductsResponseType,
TicketsResponseType,
} from "../type/Types";
import { OrderStatusEnum, TabMyOrdersEnum } from "../enum/OrderEnum";
@@ -56,3 +58,15 @@ export const getOrderDetails = async (id: string) => {
);
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;
};
+21
View File
@@ -99,3 +99,24 @@ export interface MyOrderType extends RowDataType {
export type MyOrdersResponseType = 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[]>;