From b730f86b1e6b2b7f07afcf8307893ce5bae90a6e Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sat, 31 Jan 2026 11:00:24 +0330 Subject: [PATCH] order detail + types + components --- src/components/VoicePlayer.tsx | 74 ++++++ src/config/Paths.tsx | 3 +- src/config/func.ts | 23 +- src/hooks/useVoiceRecorder.ts | 159 +++++++++++ src/pages/order/List.tsx | 10 +- src/pages/order/OrderDetail.tsx | 83 ++++++ src/pages/order/components/TicketSection.tsx | 261 +++++++++++++++++++ src/pages/order/hooks/useOrderData.ts | 31 ++- src/pages/order/service/OrderService.ts | 26 +- src/pages/order/types/Types.ts | 56 ++++ src/router/MainRouter.tsx | 3 + 11 files changed, 723 insertions(+), 6 deletions(-) create mode 100644 src/components/VoicePlayer.tsx create mode 100644 src/hooks/useVoiceRecorder.ts create mode 100644 src/pages/order/OrderDetail.tsx create mode 100644 src/pages/order/components/TicketSection.tsx diff --git a/src/components/VoicePlayer.tsx b/src/components/VoicePlayer.tsx new file mode 100644 index 0000000..b96fab0 --- /dev/null +++ b/src/components/VoicePlayer.tsx @@ -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 = ({ url }) => { + const audioRef = useRef(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 ( +
+ + +
+ {Array.from({ length: 60 }).map((_, i) => { + const passed = i / 60 <= progress + return ( +
+ ) + })} +
+ +
+ ) +} + +export default VoicePlayer \ No newline at end of file diff --git a/src/config/Paths.tsx b/src/config/Paths.tsx index 5ddb833..7b619e4 100644 --- a/src/config/Paths.tsx +++ b/src/config/Paths.tsx @@ -27,7 +27,8 @@ export const Paths = { values: '/form-builder/values/' }, order: { - list: '/order/list' + list: '/order/list', + details: '/order/detail/' }, features: { list: '/features/list', diff --git a/src/config/func.ts b/src/config/func.ts index d779275..f123496 100644 --- a/src/config/func.ts +++ b/src/config/func.ts @@ -56,7 +56,7 @@ export interface IGeneralError { export const extractErrorMessage = ( error: Error | unknown, - fallbackMessage: string = "خطایی رخ داده است", + fallbackMessage: string = "خطایی رخ داده است" ): string => { try { const axiosError = error as { response?: { data: IGeneralError } }; @@ -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: "" }; + } +}; diff --git a/src/hooks/useVoiceRecorder.ts b/src/hooks/useVoiceRecorder.ts new file mode 100644 index 0000000..a991c7c --- /dev/null +++ b/src/hooks/useVoiceRecorder.ts @@ -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(null); + const [audioFile, setAudioFile] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const mediaRecorderRef = useRef(null); + const audioRef = useRef(null); + const chunksRef = useRef([]); + const animationFrameRef = useRef(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, + }; +}; diff --git a/src/pages/order/List.tsx b/src/pages/order/List.tsx index befc997..20b3b52 100644 --- a/src/pages/order/List.tsx +++ b/src/pages/order/List.tsx @@ -4,6 +4,8 @@ import Table from '@/components/Table' import { Edit2, Eye, Receipt21, Setting2 } from 'iconsax-react' import { useGetOrders } from './hooks/useOrderData' import moment from 'moment-jalaali' +import { Link } from 'react-router-dom' +import { Paths } from '@/config/Paths' const OrdersList: FC = () => { @@ -94,10 +96,14 @@ const OrdersList: FC = () => { { key: 'actions', title: '', - render: () => { + render: (item) => { return (
- + + + diff --git a/src/pages/order/OrderDetail.tsx b/src/pages/order/OrderDetail.tsx new file mode 100644 index 0000000..6665cce --- /dev/null +++ b/src/pages/order/OrderDetail.tsx @@ -0,0 +1,83 @@ +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!) + + return ( +
+ {/* Header Section */} +
+
+ سفارش #{data?.data?.orderNumber} +
+
+ + {/* Order Information Section */} +
+
+

اطلاعات سفارش

+ + + + پیش فاکتور + +
+
+ { + data?.data?.items?.map((item) => { + return ( +
+
+ {/* Product Image */} +
+ +
+ + {/* Order Details */} +
+
+
عنوان:
+
{item.product.title}
+
+
+
نام طرح:
+
{'orderData.designer'}
+
+
+
تعداد:
+
{item.quantity}
+
+
+
تخمین زمان:
+
{data?.data?.estimatedDays}
+
+
+
+ + {/* Order Description */} +
+
شرح سفارش:
+

+ {item.description} +

+
+
+ ) + }) + } +
+
+ + {/* Bottom Section - White Card */} + +
+ ) +} + +export default OrderDetail \ No newline at end of file diff --git a/src/pages/order/components/TicketSection.tsx b/src/pages/order/components/TicketSection.tsx new file mode 100644 index 0000000..eba251b --- /dev/null +++ b/src/pages/order/components/TicketSection.tsx @@ -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 '../types/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([]) + 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 ( +
+ {/* Designer Name */} +
+ +
+ { + data?.data?.map((item) => { + if (item.user) + return ( +
+
+ {item.content} +
+ + {/* attachments (non-voice) */} +
+ {item.attachments + ?.filter((a) => a.type !== 'voice') + .map((attach) => ( +
handleOpenLink(attach.url)} + className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]" + > + +
+ {getFileNameAndExtensionFromUrl(attach.url).fileName} +
+
+ ))} +
+ + {/* voice messages */} + {item.attachments + ?.filter((a) => a.type === 'voice') + .map((voice) => ( + + ))} +
+ ) + else if (item.admin) + return ( +
+
+
+
طراح :
+
{item.admin?.firstName + ' ' + item?.admin?.lastName}
+
+
+ {item.content} +
+ + {/* attachments (non-voice) */} +
+ {item.attachments + ?.filter((a) => a.type !== 'voice') + .map((attach) => ( +
handleOpenLink(attach.url)} + className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]" + > + +
+ {getFileNameAndExtensionFromUrl(attach.url).fileName} +
+
+ ))} +
+ + {/* voice messages */} + {item.attachments + ?.filter((a) => a.type === 'voice') + .map((voice) => ( + + ))} +
+
+ ) + }) + } + + + + + + + +
+
پیام شما
+ +
+