Compare commits

...

2 Commits

Author SHA1 Message Date
morteza c01f1a32dc chat composer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 23:58:18 +03:30
morteza 026b605943 reusable chat 2026-07-17 23:33:39 +03:30
16 changed files with 1139 additions and 710 deletions
+35 -28
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
type UseVoiceRecorderOptions = { type UseVoiceRecorderOptions = {
onRecordingComplete?: (audioBlob: Blob) => void; onRecordingComplete?: (audioFile: File, audioUrl: string) => void;
}; };
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => { export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
@@ -18,6 +18,33 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const animationFrameRef = useRef<number | null>(null); 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 () => { const startRecording = useCallback(async () => {
try { try {
@@ -35,14 +62,15 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
mediaRecorder.onstop = () => { mediaRecorder.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" }); 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", type: "audio/webm",
}); });
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
audioUrlRef.current = url;
setAudioUrl(url); setAudioUrl(url);
setAudioFile(file); setAudioFile(file);
onRecordingComplete?.(audioBlob); onRecordingCompleteRef.current?.(file, url);
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
}; };
@@ -52,7 +80,7 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
} catch (err) { } catch (err) {
console.error("Microphone access error:", err); console.error("Microphone access error:", err);
} }
}, [onRecordingComplete]); }, []);
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) { if (mediaRecorderRef.current && isRecording) {
@@ -65,24 +93,9 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
if (mediaRecorderRef.current && isRecording) { if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop(); mediaRecorderRef.current.stop();
} }
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
chunksRef.current = [];
setIsRecording(false); setIsRecording(false);
setIsPlaying(false); clearRecording({ revoke: true });
setCurrentTime(0); }, [clearRecording, isRecording]);
setDuration(0);
setAudioUrl(null);
setAudioFile(null);
}, [audioUrl, isRecording]);
const togglePlayPause = useCallback(() => { const togglePlayPause = useCallback(() => {
if (!audioRef.current) return; if (!audioRef.current) return;
@@ -136,24 +149,18 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
}, [isPlaying, updateProgress]); }, [isPlaying, updateProgress]);
return { return {
// state
isRecording, isRecording,
isPlaying, isPlaying,
audioUrl, audioUrl,
audioFile, audioFile,
currentTime, currentTime,
duration, duration,
// refs
audioRef, audioRef,
// actions
startRecording, startRecording,
stopRecording, stopRecording,
togglePlayPause, togglePlayPause,
resetRecorder, resetRecorder,
clearRecording,
// helpers
progress: duration ? currentTime / duration : 0, progress: duration ? currentTime / duration : 0,
}; };
}; };
+541
View File
@@ -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
+170
View File
@@ -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
+229
View File
@@ -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
+29
View File
@@ -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),
})
}
+21
View File
@@ -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'
+25
View File
@@ -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
}
+59
View File
@@ -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 ||
''
)
}
+13 -2
View File
@@ -8,7 +8,7 @@ import Button from '@/components/Button'
import { Paths } from '@/config/Paths' import { Paths } from '@/config/Paths'
import { extractErrorMessage } from '@/config/func' import { extractErrorMessage } from '@/config/func'
import { useGetOrderDetails, useUpdateOrderStatus } from './hooks/useOrderData' import { useGetOrderDetails, useUpdateOrderStatus } from './hooks/useOrderData'
import TicketSection from './components/TicketSection' import ChatSection from '@/pages/chat/components/ChatSection'
import OrderDetailSidebar from './components/OrderDetailSidebar' import OrderDetailSidebar from './components/OrderDetailSidebar'
import type { OrderDetailDataType } from './types/Types' import type { OrderDetailDataType } from './types/Types'
import { OrderStatusEnum } from './enum/OrderEnum' import { OrderStatusEnum } from './enum/OrderEnum'
@@ -21,6 +21,10 @@ const OrderDetail: FC = () => {
const order = data?.data as OrderDetailDataType | undefined const order = data?.data as OrderDetailDataType | undefined
// const { data: invoiceItemData } = useGetInvoiceItem(order?.invoiceItem ?? null) // const { data: invoiceItemData } = useGetInvoiceItem(order?.invoiceItem ?? null)
const invoiceId = order?.invoiceItem?.invoice const invoiceId = order?.invoiceItem?.invoice
const customerName =
[order?.user?.firstName, order?.user?.lastName].filter(Boolean).join(' ') ||
order?.user?.phone ||
undefined
const handleUpdateStatus = (status: OrderStatusEnum) => { const handleUpdateStatus = (status: OrderStatusEnum) => {
if (!id) return 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="mt-6 flex flex-col-reverse gap-6 xl:flex-row xl:items-start">
<div className="min-w-0 flex-1 space-y-6"> <div className="min-w-0 flex-1 space-y-6">
<TicketSection /> <ChatSection
refId={id!}
customerName={customerName}
title="گفتگو با مشتری"
description="پیام‌ها و فایل‌های مرتبط با سفارش"
senderLabel="طراح"
userLabel="مشتری"
/>
</div> </div>
<aside className="w-full shrink-0 xl:sticky xl:top-4 xl:w-[320px]"> <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
-21
View File
@@ -1,7 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/OrderService"; import * as api from "../service/OrderService";
import type { import type {
AddTicketType,
OrderPrintFormType, OrderPrintFormType,
UpdateOrderType, UpdateOrderType,
} from "../types/Types"; } 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 = () => { export const useCreateOrder = () => {
return useMutation({ return useMutation({
mutationFn: api.submitOrder, mutationFn: api.submitOrder,
-14
View File
@@ -1,6 +1,5 @@
import axios from "@/config/axios"; import axios from "@/config/axios";
import type { import type {
AddTicketType,
ConvertToOrderItemType, ConvertToOrderItemType,
CreateFinalOrderType, CreateFinalOrderType,
OrderDetailResponseType, OrderDetailResponseType,
@@ -8,7 +7,6 @@ import type {
OrderListResponseType, OrderListResponseType,
OrderPrintFormType, OrderPrintFormType,
OrderPrintResponseType, OrderPrintResponseType,
TicketsResponseType,
UpdateOrderType, UpdateOrderType,
} from "../types/Types"; } from "../types/Types";
import type { BaseResponse } from "@/shared/types/Types"; import type { BaseResponse } from "@/shared/types/Types";
@@ -99,18 +97,6 @@ export const getOrderDetail = async (id: string) => {
return data; 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) => { export const submitOrder = async (params: CreateFinalOrderType) => {
const { data } = await axios.post(`/admin/orders`, params); const { data } = await axios.post(`/admin/orders`, params);
return data; return data;
+8 -26
View File
@@ -165,31 +165,14 @@ export type AttachmentsType = {
type: string; type: string;
}; };
export type AddTicketType = { export type {
content: string; AddChatMessageType,
attachments: AttachmentsType[]; AddTicketType,
}; ChatMessageType,
ChatMessagesResponseType,
export type TicketType = { TicketType,
admin?: { TicketsResponseType,
firstName: string; } from "@/pages/chat/type/Types";
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 interface MyOrderType extends RowDataType { export interface MyOrderType extends RowDataType {
balance: number; balance: number;
@@ -231,7 +214,6 @@ export type OrderItemType = {
fieldId: string; fieldId: string;
}[]; }[];
}; };
export type TicketsResponseType = BaseResponse<TicketType[]>;
export type OrderDetailResponseType = BaseResponse<OrderDetailDataType>; export type OrderDetailResponseType = BaseResponse<OrderDetailDataType>;
export type StoreType = { export type StoreType = {
+9 -2
View File
@@ -4,7 +4,7 @@ import moment from 'moment-jalaali'
import { Calendar, TickSquare } from 'iconsax-react' import { Calendar, TickSquare } from 'iconsax-react'
import { useGetRequestDetail } from './hooks/useRequestData' import { useGetRequestDetail } from './hooks/useRequestData'
import RequestDetailItem from './components/RequestDetailItem' import RequestDetailItem from './components/RequestDetailItem'
import TicketSection from './components/TicketSection' import ChatSection from '@/pages/chat/components/ChatSection'
import { Paths } from '@/config/Paths' import { Paths } from '@/config/Paths'
import BackButton from '@/components/BackButton' import BackButton from '@/components/BackButton'
import Button from '@/components/Button' import Button from '@/components/Button'
@@ -120,7 +120,14 @@ const RequestDetail: FC = () => {
)} )}
</section> </section>
<TicketSection customerName={customerName} /> <ChatSection
refId={id!}
customerName={customerName}
title="گفتگو با مشتری"
description="پیام‌ها و فایل‌های مرتبط با درخواست"
senderLabel="پشتیبان"
userLabel="مشتری"
/>
</div> </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