Compare commits
2 Commits
94a0091d1b
...
c01f1a32dc
| Author | SHA1 | Date | |
|---|---|---|---|
| c01f1a32dc | |||
| 026b605943 |
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
type UseVoiceRecorderOptions = {
|
||||
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||
onRecordingComplete?: (audioFile: File, audioUrl: string) => void;
|
||||
};
|
||||
|
||||
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
@@ -18,6 +18,33 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
const onRecordingCompleteRef = useRef(onRecordingComplete);
|
||||
|
||||
useEffect(() => {
|
||||
onRecordingCompleteRef.current = onRecordingComplete;
|
||||
}, [onRecordingComplete]);
|
||||
|
||||
const clearRecording = useCallback((options?: { revoke?: boolean }) => {
|
||||
const shouldRevoke = options?.revoke !== false;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
|
||||
if (shouldRevoke && audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
|
||||
audioUrlRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setAudioUrl(null);
|
||||
setAudioFile(null);
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -35,14 +62,15 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
const file = new File([audioBlob], "voice-message.webm", {
|
||||
const file = new File([audioBlob], `voice-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audioUrlRef.current = url;
|
||||
setAudioUrl(url);
|
||||
setAudioFile(file);
|
||||
onRecordingComplete?.(audioBlob);
|
||||
onRecordingCompleteRef.current?.(file, url);
|
||||
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
@@ -52,7 +80,7 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
} catch (err) {
|
||||
console.error("Microphone access error:", err);
|
||||
}
|
||||
}, [onRecordingComplete]);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
@@ -65,24 +93,9 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
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]);
|
||||
clearRecording({ revoke: true });
|
||||
}, [clearRecording, isRecording]);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
@@ -136,24 +149,18 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
}, [isPlaying, updateProgress]);
|
||||
|
||||
return {
|
||||
// state
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioFile,
|
||||
currentTime,
|
||||
duration,
|
||||
|
||||
// refs
|
||||
audioRef,
|
||||
|
||||
// actions
|
||||
startRecording,
|
||||
stopRecording,
|
||||
togglePlayPause,
|
||||
resetRecorder,
|
||||
|
||||
// helpers
|
||||
clearRecording,
|
||||
progress: duration ? currentTime / duration : 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import Button from '@/components/Button'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import { toast } from '@/shared/toast'
|
||||
import {
|
||||
CloseCircle,
|
||||
DocumentText,
|
||||
Microphone,
|
||||
Paperclip2,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'iconsax-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FC,
|
||||
} from 'react'
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ChatComposerSubmitPayload = {
|
||||
content: string
|
||||
attachments: ChatComposerAttachment[]
|
||||
}
|
||||
|
||||
type PendingFile = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string | null
|
||||
key?: string
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
type PendingVoice = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
key?: string
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
replyTo?: {
|
||||
id: string
|
||||
content: string
|
||||
senderName?: string
|
||||
} | null
|
||||
onCancelReply?: () => void
|
||||
}
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
|
||||
const VoicePreviewChip: FC<{
|
||||
voice: PendingVoice
|
||||
onRemove: () => void
|
||||
}> = ({ voice, onRemove }) => {
|
||||
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 onTimeUpdate = () => {
|
||||
if (!audio.duration) return
|
||||
setProgress(audio.currentTime / audio.duration)
|
||||
}
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false)
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
void audio.play()
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-16 w-[140px] items-center gap-2 rounded-lg border border-[#EBEDF5] bg-[#F5F7FC] px-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
disabled={voice.status === 'uploading'}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-black disabled:opacity-50"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex h-8 flex-1 items-center justify-center gap-[2px]">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const passed = i / 12 <= progress
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all"
|
||||
style={{
|
||||
height: `${6 + ((i * 5) % 12)}px`,
|
||||
backgroundColor: passed ? '#0047FF' : '#E5E7EB',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{voice.status === 'uploading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/35">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voice.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/70 px-1">
|
||||
<span className="text-center text-[10px] text-white">خطا</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 rounded-full bg-white shadow-sm"
|
||||
aria-label="حذف صدا"
|
||||
>
|
||||
<CloseCircle size={18} color="#EF4444" variant="Bold" />
|
||||
</button>
|
||||
|
||||
<audio ref={audioRef} src={voice.previewUrl} className="hidden" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatComposer: FC<Props> = ({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'ارسال پیام',
|
||||
label = 'پیام شما',
|
||||
placeholder = 'متن پیام خود را بنویسید...',
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
const inputId = useId()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([])
|
||||
const [pendingVoices, setPendingVoices] = useState<PendingVoice[]>([])
|
||||
const singleUpload = useSingleUpload()
|
||||
const clearRecordingRef = useRef<(options?: { revoke?: boolean }) => void>(
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
const uploadVoice = useCallback(
|
||||
async (item: PendingVoice) => {
|
||||
try {
|
||||
const result = await singleUpload.mutateAsync(item.file)
|
||||
setPendingVoices((prev) =>
|
||||
prev.map((voice) =>
|
||||
voice.id === item.id
|
||||
? { ...voice, key: result.data.key, status: 'done' }
|
||||
: voice,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
setPendingVoices((prev) =>
|
||||
prev.map((voice) =>
|
||||
voice.id === item.id
|
||||
? { ...voice, status: 'error' }
|
||||
: voice,
|
||||
),
|
||||
)
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
},
|
||||
[singleUpload],
|
||||
)
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
(file: File, url: string) => {
|
||||
const item: PendingVoice = {
|
||||
id: `voice-${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: url,
|
||||
status: 'uploading',
|
||||
}
|
||||
setPendingVoices((prev) => [...prev, item])
|
||||
clearRecordingRef.current({ revoke: false })
|
||||
void uploadVoice(item)
|
||||
},
|
||||
[uploadVoice],
|
||||
)
|
||||
|
||||
const { isRecording, startRecording, stopRecording, clearRecording } =
|
||||
useVoiceRecorder({
|
||||
onRecordingComplete: handleRecordingComplete,
|
||||
})
|
||||
|
||||
clearRecordingRef.current = clearRecording
|
||||
|
||||
const isUploading =
|
||||
pendingFiles.some((f) => f.status === 'uploading') ||
|
||||
pendingVoices.some((v) => v.status === 'uploading')
|
||||
|
||||
const isBusy = isSubmitting || singleUpload.isPending || isUploading
|
||||
|
||||
const revokePreview = (previewUrl: string | null) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
|
||||
const resetComposer = useCallback(() => {
|
||||
setPendingFiles((prev) => {
|
||||
prev.forEach((item) => revokePreview(item.previewUrl))
|
||||
return []
|
||||
})
|
||||
setPendingVoices((prev) => {
|
||||
prev.forEach((item) => revokePreview(item.previewUrl))
|
||||
return []
|
||||
})
|
||||
setMessage('')
|
||||
clearRecording({ revoke: true })
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [clearRecording])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingFiles.forEach((item) => revokePreview(item.previewUrl))
|
||||
pendingVoices.forEach((item) => revokePreview(item.previewUrl))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const uploadFile = async (item: PendingFile) => {
|
||||
try {
|
||||
const result = await singleUpload.mutateAsync(item.file)
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
file.id === item.id
|
||||
? { ...file, key: result.data.key, status: 'done' }
|
||||
: file,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
file.id === item.id ? { ...file, status: 'error' } : file,
|
||||
),
|
||||
)
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesSelected = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const selected = Array.from(event.target.files ?? [])
|
||||
if (!selected.length) return
|
||||
|
||||
const nextItems: PendingFile[] = selected.map((file) => ({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: isImageFile(file) ? URL.createObjectURL(file) : null,
|
||||
status: 'uploading',
|
||||
}))
|
||||
|
||||
setPendingFiles((prev) => [...prev, ...nextItems])
|
||||
event.target.value = ''
|
||||
|
||||
for (const item of nextItems) {
|
||||
await uploadFile(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (id: string) => {
|
||||
setPendingFiles((prev) => {
|
||||
const target = prev.find((item) => item.id === id)
|
||||
revokePreview(target?.previewUrl ?? null)
|
||||
return prev.filter((item) => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveVoice = (id: string) => {
|
||||
setPendingVoices((prev) => {
|
||||
const target = prev.find((item) => item.id === id)
|
||||
revokePreview(target?.previewUrl ?? null)
|
||||
return prev.filter((item) => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const uploadedFiles = pendingFiles.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
const uploadedVoices = pendingVoices.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
|
||||
if (
|
||||
!message.trim() &&
|
||||
uploadedFiles.length === 0 &&
|
||||
uploadedVoices.length === 0
|
||||
) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
toast('لطفاً تا پایان آپلود فایلها صبر کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
pendingFiles.some((item) => item.status === 'error') ||
|
||||
pendingVoices.some((item) => item.status === 'error')
|
||||
) {
|
||||
toast(
|
||||
'برخی فایلها آپلود نشدند. آنها را حذف یا دوباره انتخاب کنید',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
})
|
||||
resetComposer()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const hasAttachments =
|
||||
pendingFiles.length > 0 || pendingVoices.length > 0 || isRecording
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <div className="text-sm mb-2 text-black">{label}</div>}
|
||||
|
||||
{replyTo && (
|
||||
<div className="mb-3 flex items-start justify-between gap-3 rounded-xl border border-[#D6E0FF] bg-[#F5F8FF] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-[#0047FF]">
|
||||
در پاسخ به
|
||||
{replyTo.senderName ? ` ${replyTo.senderName}` : ''}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[#8C90A3]">
|
||||
{replyTo.content || 'پیام ضمیمهدار'}
|
||||
</p>
|
||||
</div>
|
||||
{onCancelReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="shrink-0"
|
||||
aria-label="لغو پاسخ"
|
||||
>
|
||||
<CloseCircle size={18} color="#8C90A3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white transition-colors focus-within:border-[#0047FF]">
|
||||
{hasAttachments && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[#EBEDF5] px-3 pt-3 pb-2">
|
||||
{pendingFiles.map((item) => (
|
||||
<div key={item.id} className="relative size-16">
|
||||
<div className="relative size-full overflow-hidden rounded-lg border border-[#EBEDF5] bg-[#F5F7FC]">
|
||||
{item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={item.file.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-1 px-1">
|
||||
<DocumentText
|
||||
size={20}
|
||||
color="#0047FF"
|
||||
/>
|
||||
<span className="w-full truncate text-center text-[10px] text-[#8C90A3]">
|
||||
{item.file.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/35">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/70 px-1">
|
||||
<span className="text-center text-[10px] text-white">
|
||||
خطا
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(item.id)}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 rounded-full bg-white shadow-sm"
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
<CloseCircle
|
||||
size={18}
|
||||
color="#EF4444"
|
||||
variant="Bold"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pendingVoices.map((voice) => (
|
||||
<VoicePreviewChip
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
onRemove={() => handleRemoveVoice(voice.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex h-16 items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3">
|
||||
<span className="size-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="text-xs text-red-600">
|
||||
در حال ضبط...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full h-32 resize-none bg-transparent p-4 pb-14 text-sm outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-3 left-3 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFilesSelected}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-[#EEF2FF] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
aria-label="افزودن فایل"
|
||||
>
|
||||
<Paperclip2 size={18} color="#0047FF" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
isRecording ? stopRecording : startRecording
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-[#FFF1D7] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
aria-label={
|
||||
isRecording ? 'توقف ضبط' : 'ضبط صدا'
|
||||
}
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : 'black'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatComposer
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type FC } from 'react'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Paperclip2, ArrowRotateLeft } from 'iconsax-react'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import type { ChatAttachmentType, ChatParentMessageType } from '../type/Types'
|
||||
import { getChatSenderName } from '../type/Types'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: ChatAttachmentType[]
|
||||
senderName?: string
|
||||
senderLabel?: string
|
||||
createdAt?: string
|
||||
isAdmin?: boolean
|
||||
avatarUrl?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
parent?: ChatParentMessageType | null
|
||||
parentSenderLabel?: string
|
||||
onReply?: () => void
|
||||
canDelete?: boolean
|
||||
onDelete?: () => void
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
const ChatMessage: FC<Props> = ({
|
||||
content,
|
||||
attachments = [],
|
||||
senderName,
|
||||
senderLabel = 'پشتیبان',
|
||||
createdAt,
|
||||
isAdmin = false,
|
||||
avatarUrl,
|
||||
firstName,
|
||||
lastName,
|
||||
parent,
|
||||
parentSenderLabel,
|
||||
onReply,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}) => {
|
||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
||||
|
||||
const handleOpenLink = async (key: string) => {
|
||||
const url = await getPresignedUrl(key)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const parentName = parent
|
||||
? getChatSenderName(parent.admin || parent.user)
|
||||
: ''
|
||||
|
||||
const avatar = (
|
||||
<UserAvatar
|
||||
src={avatarUrl}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isAdmin ? 'justify-end' : ''}`}>
|
||||
{!isAdmin && avatar}
|
||||
<div
|
||||
className={`bg-[#F5F7FC] rounded-3xl p-5 max-w-[min(100%,520px)] ${
|
||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">
|
||||
{senderLabel}:
|
||||
</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{onReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReply}
|
||||
className="flex items-center gap-1 text-[11px] text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||
aria-label="پاسخ"
|
||||
>
|
||||
<ArrowRotateLeft size={14} color="#0047FF" />
|
||||
پاسخ
|
||||
</button>
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<TrashWithConfrim
|
||||
onDelete={onDelete}
|
||||
isloading={isDeleting}
|
||||
colorIcon="#EF4444"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parent && (
|
||||
<div className="mb-3 rounded-xl border border-[#D6E0FF] bg-white/70 px-3 py-2">
|
||||
<div className="text-[11px] text-[#0047FF]">
|
||||
در پاسخ به
|
||||
{parentSenderLabel || parentName
|
||||
? ` ${parentSenderLabel || parentName}`
|
||||
: ''}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[#8C90A3]">
|
||||
{parent.content || 'پیام ضمیمهدار'}
|
||||
</p>
|
||||
</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>
|
||||
{isAdmin && avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatMessage
|
||||
@@ -0,0 +1,229 @@
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import { useMemo, useState, type FC } from 'react'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useGetAdminMe } from '@/pages/admin/hooks/useAdminData'
|
||||
import { getChatSenderName, type ChatMessageType } from '../type/Types'
|
||||
import {
|
||||
useAddChatMessage,
|
||||
useDeleteChatMessage,
|
||||
useGetChatMessages,
|
||||
} from '../hooks/useChatData'
|
||||
import ChatComposer, {
|
||||
type ChatComposerSubmitPayload,
|
||||
} from './ChatComposer'
|
||||
import ChatMessage from './ChatMessage'
|
||||
|
||||
export type ChatSectionProps = {
|
||||
refId: string
|
||||
title?: string
|
||||
description?: string
|
||||
/** Label for admin / support / designer side */
|
||||
senderLabel?: string
|
||||
/** Label for customer / user side */
|
||||
userLabel?: string
|
||||
/** Optional fallback when message.user has no name */
|
||||
customerName?: string
|
||||
submitLabel?: string
|
||||
showRefresh?: boolean
|
||||
}
|
||||
|
||||
type ReplyTarget = {
|
||||
id: string
|
||||
content: string
|
||||
senderName?: string
|
||||
}
|
||||
|
||||
const ChatSection: FC<ChatSectionProps> = ({
|
||||
refId,
|
||||
title = 'گفتگو',
|
||||
description,
|
||||
senderLabel = 'پشتیبان',
|
||||
userLabel = 'مشتری',
|
||||
customerName,
|
||||
submitLabel = 'ارسال پیام',
|
||||
showRefresh = true,
|
||||
}) => {
|
||||
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null)
|
||||
const addMessage = useAddChatMessage()
|
||||
const deleteMessage = useDeleteChatMessage()
|
||||
const { data: adminMe } = useGetAdminMe()
|
||||
const currentAdminId = adminMe?.data?.id
|
||||
const {
|
||||
data,
|
||||
refetch,
|
||||
isFetching,
|
||||
isPending: isLoadingMessages,
|
||||
} = useGetChatMessages(refId)
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const list = data?.data ?? []
|
||||
return [...list].reverse()
|
||||
}, [data?.data])
|
||||
|
||||
const buildReplyTarget = (item: ChatMessageType): ReplyTarget => {
|
||||
const participant = item.admin || item.user
|
||||
return {
|
||||
id: String(item.id),
|
||||
content: item.content,
|
||||
senderName: getChatSenderName(
|
||||
participant,
|
||||
item.user ? customerName : undefined,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: ChatComposerSubmitPayload) => {
|
||||
await addMessage.mutateAsync({
|
||||
refId,
|
||||
params: {
|
||||
content: payload.content,
|
||||
attachments: payload.attachments,
|
||||
...(replyTo ? { parentId: replyTo.id } : {}),
|
||||
},
|
||||
})
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
setReplyTo(null)
|
||||
refetch()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMessage.mutateAsync(id)
|
||||
toast('پیام حذف شد')
|
||||
if (replyTo?.id === id) setReplyTo(null)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#EBEDF5] px-6 py-4 md:px-8">
|
||||
<div>
|
||||
<h2 className="text-sm text-black">{title}</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-[#8C90A3]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showRefresh && (
|
||||
<RefreshButton
|
||||
onClick={() => refetch()}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="space-y-4 min-h-[80px]">
|
||||
{isLoadingMessages && messages.length === 0 && (
|
||||
<p className="text-xs text-[#8C90A3]">
|
||||
در حال بارگذاری پیامها...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && messages.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>
|
||||
)}
|
||||
|
||||
{messages.map((item) => {
|
||||
if (item.user) {
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
senderLabel={userLabel}
|
||||
senderName={getChatSenderName(
|
||||
item.user,
|
||||
customerName,
|
||||
)}
|
||||
avatarUrl={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
parent={item.parent}
|
||||
parentSenderLabel={
|
||||
item.parent?.admin
|
||||
? senderLabel
|
||||
: item.parent?.user
|
||||
? userLabel
|
||||
: undefined
|
||||
}
|
||||
onReply={() =>
|
||||
setReplyTo(buildReplyTarget(item))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.admin) {
|
||||
const isOwnMessage =
|
||||
!!currentAdminId &&
|
||||
item.admin.id === currentAdminId
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
isAdmin
|
||||
senderLabel={senderLabel}
|
||||
senderName={getChatSenderName(item.admin)}
|
||||
avatarUrl={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={item.admin.lastName}
|
||||
parent={item.parent}
|
||||
parentSenderLabel={
|
||||
item.parent?.admin
|
||||
? senderLabel
|
||||
: item.parent?.user
|
||||
? userLabel
|
||||
: undefined
|
||||
}
|
||||
onReply={() =>
|
||||
setReplyTo(buildReplyTarget(item))
|
||||
}
|
||||
canDelete={isOwnMessage}
|
||||
onDelete={() =>
|
||||
handleDelete(String(item.id))
|
||||
}
|
||||
isDeleting={
|
||||
deleteMessage.isPending &&
|
||||
deleteMessage.variables ===
|
||||
String(item.id)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-[#EBEDF5] pt-6">
|
||||
<ChatComposer
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={addMessage.isPending}
|
||||
submitLabel={submitLabel}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatSection
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import * as api from '../service/ChatService'
|
||||
import type { AddChatMessageType } from '../type/Types'
|
||||
|
||||
export const useGetChatMessages = (refId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['chat', refId],
|
||||
queryFn: () => api.getChatMessages(refId),
|
||||
enabled: !!refId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useAddChatMessage = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
refId,
|
||||
params,
|
||||
}: {
|
||||
refId: string
|
||||
params: AddChatMessageType
|
||||
}) => api.addChatMessage(refId, params),
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteChatMessage = () => {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteChatMessage(id),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export { default as ChatSection } from './components/ChatSection'
|
||||
export { default as ChatMessage } from './components/ChatMessage'
|
||||
export { default as ChatComposer } from './components/ChatComposer'
|
||||
export type { ChatSectionProps } from './components/ChatSection'
|
||||
export type {
|
||||
ChatComposerAttachment,
|
||||
ChatComposerSubmitPayload,
|
||||
} from './components/ChatComposer'
|
||||
export {
|
||||
useGetChatMessages,
|
||||
useAddChatMessage,
|
||||
} from './hooks/useChatData'
|
||||
export type {
|
||||
AddChatMessageType,
|
||||
ChatAttachmentType,
|
||||
ChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
ChatParentMessageType,
|
||||
ChatParticipantType,
|
||||
} from './type/Types'
|
||||
export { getChatSenderName } from './type/Types'
|
||||
@@ -0,0 +1,25 @@
|
||||
import axios from '@/config/axios'
|
||||
import type {
|
||||
AddChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
} from '../type/Types'
|
||||
|
||||
export const getChatMessages = async (refId: string) => {
|
||||
const { data } = await axios.get<ChatMessagesResponseType>(
|
||||
`/admin/chat/ref/${refId}`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const addChatMessage = async (
|
||||
refId: string,
|
||||
params: AddChatMessageType,
|
||||
) => {
|
||||
const { data } = await axios.post(`/admin/chat/ref/${refId}`, params)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteChatMessage = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/chat/${id}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BaseResponse } from '@/shared/types/Types'
|
||||
|
||||
export type ChatAttachmentType = {
|
||||
url: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AddChatMessageType = {
|
||||
content: string
|
||||
attachments: ChatAttachmentType[]
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export type ChatParticipantType = {
|
||||
id: string
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
phone?: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type ChatParentMessageType = {
|
||||
id: string
|
||||
content: string
|
||||
admin?: ChatParticipantType | null
|
||||
user?: ChatParticipantType | null
|
||||
}
|
||||
|
||||
export type ChatMessageType = {
|
||||
admin?: ChatParticipantType | null
|
||||
user?: ChatParticipantType | null
|
||||
attachments: ChatAttachmentType[]
|
||||
content: string
|
||||
createdAt: string
|
||||
id: string
|
||||
parent?: ChatParentMessageType | null
|
||||
}
|
||||
|
||||
export type ChatMessagesResponseType = BaseResponse<ChatMessageType[]>
|
||||
|
||||
/** @deprecated Use AddChatMessageType */
|
||||
export type AddTicketType = AddChatMessageType
|
||||
/** @deprecated Use ChatMessageType */
|
||||
export type TicketType = ChatMessageType
|
||||
/** @deprecated Use ChatMessagesResponseType */
|
||||
export type TicketsResponseType = ChatMessagesResponseType
|
||||
|
||||
export const getChatSenderName = (
|
||||
participant?: ChatParticipantType | null,
|
||||
fallback?: string,
|
||||
): string => {
|
||||
if (!participant && !fallback) return ''
|
||||
return (
|
||||
[participant?.firstName, participant?.lastName].filter(Boolean).join(' ') ||
|
||||
participant?.phone ||
|
||||
fallback ||
|
||||
''
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import Button from '@/components/Button'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useGetOrderDetails, useUpdateOrderStatus } from './hooks/useOrderData'
|
||||
import TicketSection from './components/TicketSection'
|
||||
import ChatSection from '@/pages/chat/components/ChatSection'
|
||||
import OrderDetailSidebar from './components/OrderDetailSidebar'
|
||||
import type { OrderDetailDataType } from './types/Types'
|
||||
import { OrderStatusEnum } from './enum/OrderEnum'
|
||||
@@ -21,6 +21,10 @@ const OrderDetail: FC = () => {
|
||||
const order = data?.data as OrderDetailDataType | undefined
|
||||
// const { data: invoiceItemData } = useGetInvoiceItem(order?.invoiceItem ?? null)
|
||||
const invoiceId = order?.invoiceItem?.invoice
|
||||
const customerName =
|
||||
[order?.user?.firstName, order?.user?.lastName].filter(Boolean).join(' ') ||
|
||||
order?.user?.phone ||
|
||||
undefined
|
||||
|
||||
const handleUpdateStatus = (status: OrderStatusEnum) => {
|
||||
if (!id) return
|
||||
@@ -101,7 +105,14 @@ const OrderDetail: FC = () => {
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-6 xl:flex-row xl:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
<TicketSection />
|
||||
<ChatSection
|
||||
refId={id!}
|
||||
customerName={customerName}
|
||||
title="گفتگو با مشتری"
|
||||
description="پیامها و فایلهای مرتبط با سفارش"
|
||||
senderLabel="طراح"
|
||||
userLabel="مشتری"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="w-full shrink-0 xl:sticky xl:top-4 xl:w-[320px]">
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
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 { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
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, isFetching } = 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) {
|
||||
const voiceResult = await singleUpload.mutateAsync(audioFile)
|
||||
params.attachments.push({
|
||||
type: 'voice',
|
||||
url: voiceResult.data.key,
|
||||
})
|
||||
}
|
||||
if (files.length) {
|
||||
const filesResult = await multiUpload.mutateAsync(files)
|
||||
filesResult.data?.forEach((item) => {
|
||||
params.attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
addTicket.mutate({ orderId: id!, params: params }, {
|
||||
onSuccess: () => {
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
refetch()
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
resetRecorder()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenLink = async (key: string) => {
|
||||
const url = await getPresignedUrl(key)
|
||||
window.open(
|
||||
url,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4 md:px-6">
|
||||
<div>
|
||||
<h2 className="text-base font-medium text-gray-900">گفتگو</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">پیامها و فایلهای مرتبط با سفارش</p>
|
||||
</div>
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
</div>
|
||||
<div className="p-5 md:p-6">
|
||||
{
|
||||
data?.data?.map((item) => {
|
||||
if (item.user)
|
||||
return (
|
||||
<div key={item.id} className='flex gap-3 mt-6'>
|
||||
<UserAvatar
|
||||
src={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
<div className='bg-[#F5F7FC] rounded-4xl rounded-tr-none 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>
|
||||
</div>
|
||||
)
|
||||
else if (item.admin)
|
||||
return (
|
||||
<div key={item.id} className='flex justify-end gap-3 mt-6'>
|
||||
<div className='bg-[#F5F7FC] rounded-4xl rounded-tl-none 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>
|
||||
<UserAvatar
|
||||
src={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={item.admin.lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketSection
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/OrderService";
|
||||
import type {
|
||||
AddTicketType,
|
||||
OrderPrintFormType,
|
||||
UpdateOrderType,
|
||||
} from "../types/Types";
|
||||
@@ -44,26 +43,6 @@ export const useGetOrderDetails = (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateOrder = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.submitOrder,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
AddTicketType,
|
||||
ConvertToOrderItemType,
|
||||
CreateFinalOrderType,
|
||||
OrderDetailResponseType,
|
||||
@@ -8,7 +7,6 @@ import type {
|
||||
OrderListResponseType,
|
||||
OrderPrintFormType,
|
||||
OrderPrintResponseType,
|
||||
TicketsResponseType,
|
||||
UpdateOrderType,
|
||||
} from "../types/Types";
|
||||
import type { BaseResponse } from "@/shared/types/Types";
|
||||
@@ -99,18 +97,6 @@ export const getOrderDetail = async (id: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getTickets = async (orderId: string) => {
|
||||
const { data } = await axios.get<TicketsResponseType>(
|
||||
`/admin/chat/ref/${orderId}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const addTicket = async (ticketId: string, params: AddTicketType) => {
|
||||
const { data } = await axios.post(`/admin/chat/ref/${ticketId}`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const submitOrder = async (params: CreateFinalOrderType) => {
|
||||
const { data } = await axios.post(`/admin/orders`, params);
|
||||
return data;
|
||||
|
||||
@@ -165,31 +165,14 @@ export type AttachmentsType = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type AddTicketType = {
|
||||
content: string;
|
||||
attachments: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type TicketType = {
|
||||
admin?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
id: string;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
attachments: AttachmentsType[];
|
||||
content: string;
|
||||
createdAt: string;
|
||||
id: number;
|
||||
user?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
phone?: string;
|
||||
avatarUrl?: string | null;
|
||||
} | null;
|
||||
};
|
||||
export type {
|
||||
AddChatMessageType,
|
||||
AddTicketType,
|
||||
ChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
TicketType,
|
||||
TicketsResponseType,
|
||||
} from "@/pages/chat/type/Types";
|
||||
|
||||
export interface MyOrderType extends RowDataType {
|
||||
balance: number;
|
||||
@@ -231,7 +214,6 @@ export type OrderItemType = {
|
||||
fieldId: string;
|
||||
}[];
|
||||
};
|
||||
export type TicketsResponseType = BaseResponse<TicketType[]>;
|
||||
export type OrderDetailResponseType = BaseResponse<OrderDetailDataType>;
|
||||
|
||||
export type StoreType = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import moment from 'moment-jalaali'
|
||||
import { Calendar, TickSquare } from 'iconsax-react'
|
||||
import { useGetRequestDetail } from './hooks/useRequestData'
|
||||
import RequestDetailItem from './components/RequestDetailItem'
|
||||
import TicketSection from './components/TicketSection'
|
||||
import ChatSection from '@/pages/chat/components/ChatSection'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import BackButton from '@/components/BackButton'
|
||||
import Button from '@/components/Button'
|
||||
@@ -120,7 +120,14 @@ const RequestDetail: FC = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<TicketSection customerName={customerName} />
|
||||
<ChatSection
|
||||
refId={id!}
|
||||
customerName={customerName}
|
||||
title="گفتگو با مشتری"
|
||||
description="پیامها و فایلهای مرتبط با درخواست"
|
||||
senderLabel="پشتیبان"
|
||||
userLabel="مشتری"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { type FC } from 'react'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Paperclip2 } from 'iconsax-react'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import type { AttachmentsType } from '@/pages/order/types/Types'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: AttachmentsType[]
|
||||
senderName?: string
|
||||
senderLabel?: string
|
||||
createdAt?: string
|
||||
isAdmin?: boolean
|
||||
avatarUrl?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
}
|
||||
|
||||
const TicketMessage: FC<Props> = ({
|
||||
content,
|
||||
attachments = [],
|
||||
senderName,
|
||||
senderLabel = 'پشتیبان',
|
||||
createdAt,
|
||||
isAdmin = false,
|
||||
avatarUrl,
|
||||
firstName,
|
||||
lastName,
|
||||
}) => {
|
||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
||||
|
||||
const handleOpenLink = async (key: string) => {
|
||||
const url = await getPresignedUrl(key)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const avatar = (
|
||||
<UserAvatar
|
||||
src={avatarUrl}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isAdmin ? 'justify-end' : ''}`}>
|
||||
{!isAdmin && avatar}
|
||||
<div
|
||||
className={`bg-[#F5F7FC] rounded-3xl p-5 max-w-[min(100%,520px)] ${
|
||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">{senderLabel}:</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
</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>
|
||||
{isAdmin && avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketMessage
|
||||
@@ -1,240 +0,0 @@
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import { Microphone, Play, Pause } from 'iconsax-react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import type { AddTicketType } from '@/pages/order/types/Types'
|
||||
import { useAddTicket, useGetTickets } from '@/pages/order/hooks/useOrderData'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import TicketMessage from './TicketMessage'
|
||||
|
||||
type Props = {
|
||||
customerName?: string
|
||||
}
|
||||
|
||||
const TicketSection: FC<Props> = ({ customerName }) => {
|
||||
const { id } = useParams()
|
||||
const [message, setMessage] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const addTicket = useAddTicket()
|
||||
const { data, refetch, isFetching, isPending: isLoadingTickets } = useGetTickets(id!)
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioRef,
|
||||
togglePlayPause,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
progress,
|
||||
audioFile,
|
||||
resetRecorder,
|
||||
} = useVoiceRecorder()
|
||||
|
||||
const tickets = data?.data ?? []
|
||||
const isSubmitting =
|
||||
singleUpload.isPending || multiUpload.isPending || addTicket.isPending
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!message.trim() && !audioFile && files.length === 0) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const params: AddTicketType = {
|
||||
attachments: [],
|
||||
content: message.trim(),
|
||||
}
|
||||
|
||||
if (audioFile) {
|
||||
const voiceResult = await singleUpload.mutateAsync(audioFile)
|
||||
params.attachments.push({
|
||||
type: 'voice',
|
||||
url: voiceResult.data.key,
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
const filesResult = await multiUpload.mutateAsync(files)
|
||||
filesResult.data?.forEach((item) => {
|
||||
params.attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
addTicket.mutate(
|
||||
{ orderId: id!, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
refetch()
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
resetRecorder()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#EBEDF5] px-6 py-4 md:px-8">
|
||||
<div>
|
||||
<h2 className="text-sm text-black">گفتگو با مشتری</h2>
|
||||
<p className="mt-1 text-xs text-[#8C90A3]">
|
||||
پیامها و فایلهای مرتبط با درخواست
|
||||
</p>
|
||||
</div>
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="space-y-4 min-h-[80px]">
|
||||
{isLoadingTickets && tickets.length === 0 && (
|
||||
<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 (
|
||||
<TicketMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
senderLabel="مشتری"
|
||||
senderName={
|
||||
customerName ||
|
||||
[item.user.firstName, item.user.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ') ||
|
||||
item.user.phone
|
||||
}
|
||||
avatarUrl={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.admin) {
|
||||
return (
|
||||
<TicketMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
isAdmin
|
||||
senderLabel="پشتیبان"
|
||||
senderName={`${item.admin.firstName} ${item.admin.lastName}`}
|
||||
avatarUrl={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={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="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
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="متن پیام خود را بنویسید..."
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
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
|
||||
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
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
className="w-8 h-8 rounded-full bg-black flex items-center justify-center shrink-0"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{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 onChange={setFiles} />
|
||||
</div>
|
||||
|
||||
<div className="flex mt-8 justify-end">
|
||||
<Button
|
||||
label="ارسال پیام"
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketSection
|
||||
Reference in New Issue
Block a user