attachment + download attach + suggestion
This commit is contained in:
@@ -6,28 +6,34 @@ import { useDropzone } from 'react-dropzone'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string
|
title?: string
|
||||||
|
onChange?: (files: File[]) => void
|
||||||
|
onRemove?: (files: File[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadButton: FC<Props> = (props) => {
|
const UploadButton: FC<Props> = (props) => {
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { title } = props
|
const { title, onChange, onRemove } = props
|
||||||
|
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
setFiles((prev) => [...prev, ...acceptedFiles])
|
const updatedFiles = [...files, ...acceptedFiles]
|
||||||
}, [])
|
setFiles(updatedFiles)
|
||||||
|
onChange?.(updatedFiles)
|
||||||
|
}, [files, onChange])
|
||||||
|
|
||||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
const array = [...files]
|
const updatedFiles = files.filter((_, i) => i !== index)
|
||||||
array.splice(index, 1)
|
setFiles(updatedFiles)
|
||||||
setFiles(array)
|
onRemove?.(updatedFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4 sm:flex-row'>
|
<div className='flex flex-col gap-4 sm:flex-row'>
|
||||||
<div {...getRootProps()} className='h-10 bg-[#ECEEF5] rounded-full flex gap-2 items-center px-3 cursor-pointer'>
|
<div {...getRootProps()} className='h-10 bg-[#ECEEF5] rounded-full flex gap-2 items-center px-3 cursor-pointer'>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import UploadButton from '@/components/UploadButton'
|
||||||
|
import { EmailAttachmentDto } from '@/pages/received/types/Types'
|
||||||
|
import { toast } from '@/components/Toast'
|
||||||
|
|
||||||
|
interface EmailAttachmentsProps {
|
||||||
|
attachments: EmailAttachmentDto[]
|
||||||
|
onAttachmentsChange: (attachments: EmailAttachmentDto[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailAttachments: FC<EmailAttachmentsProps> = ({
|
||||||
|
attachments,
|
||||||
|
onAttachmentsChange
|
||||||
|
}) => {
|
||||||
|
const convertFileToBase64 = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
const base64 = result.split(',')[1]
|
||||||
|
resolve(base64)
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilesUpload = async (files: File[]) => {
|
||||||
|
try {
|
||||||
|
const newAttachments: EmailAttachmentDto[] = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const base64Content = await convertFileToBase64(file)
|
||||||
|
const attachment: EmailAttachmentDto = {
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
encoding: 'base64',
|
||||||
|
contentDisposition: 'attachment',
|
||||||
|
content: base64Content
|
||||||
|
}
|
||||||
|
newAttachments.push(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
onAttachmentsChange([...attachments, ...newAttachments])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting files to base64:', error)
|
||||||
|
toast('خطا در آپلود فایلها', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFile = (files: File[]) => {
|
||||||
|
const currentFileNames = files.map(f => f.name)
|
||||||
|
const updatedAttachments = attachments.filter(att =>
|
||||||
|
currentFileNames.includes(att.filename || '')
|
||||||
|
)
|
||||||
|
onAttachmentsChange(updatedAttachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UploadButton
|
||||||
|
onChange={handleFilesUpload}
|
||||||
|
onRemove={handleRemoveFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailAttachments
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import ReactQuill from 'react-quill-new'
|
||||||
|
|
||||||
|
interface EmailEditorProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailEditor: FC<EmailEditorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = ''
|
||||||
|
}) => {
|
||||||
|
const modules = {
|
||||||
|
toolbar: [
|
||||||
|
[{ header: '1' }, { header: '2' }, { font: [] }],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
['link', 'image'],
|
||||||
|
[{ align: [] }],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactQuill
|
||||||
|
modules={modules}
|
||||||
|
theme="snow"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ minHeight: '120px' }}
|
||||||
|
className='text-sm'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailEditor
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Select from '@/components/Select'
|
||||||
|
|
||||||
|
interface EmailFormActionsProps {
|
||||||
|
priority: string
|
||||||
|
onPriorityChange: (priority: string) => void
|
||||||
|
onSend: () => void
|
||||||
|
onSaveDraft: () => void
|
||||||
|
isSending: boolean
|
||||||
|
isSavingDraft: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailFormActions: FC<EmailFormActionsProps> = ({
|
||||||
|
priority,
|
||||||
|
onPriorityChange,
|
||||||
|
onSend,
|
||||||
|
onSaveDraft,
|
||||||
|
isSending,
|
||||||
|
isSavingDraft
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('global')
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ value: 'high', label: 'بالا' },
|
||||||
|
{ value: 'medium', label: 'متوسط' },
|
||||||
|
{ value: 'low', label: 'پایین' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4 sm:flex-row pt-4'>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
items={priorityOptions}
|
||||||
|
placeholder={t('new_message.select_priority')}
|
||||||
|
className='xl:w-[190px] min-w-[130px]'
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => onPriorityChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 justify-end flex gap-3'>
|
||||||
|
<Button
|
||||||
|
className='!w-full sm:!w-fit px-6 md:px-10 bg-white text-black border border-primary order-2 sm:order-1'
|
||||||
|
label={t('new_message.draft')}
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
loading={isSavingDraft}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className='w-full sm:w-fit px-6 md:px-10 order-1 sm:order-2'
|
||||||
|
label={t('new_message.send')}
|
||||||
|
onClick={onSend}
|
||||||
|
loading={isSending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailFormActions
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { FC, useState, useRef, useEffect } from 'react'
|
||||||
|
import { CloseCircle } from 'iconsax-react'
|
||||||
|
import { EmailSuggestion } from '@/services/EmailSuggestionsService'
|
||||||
|
|
||||||
|
interface EmailInputProps {
|
||||||
|
toEmails: string[]
|
||||||
|
onAddEmail: (email: string) => void
|
||||||
|
onRemoveEmail: (email: string) => void
|
||||||
|
suggestions: EmailSuggestion[]
|
||||||
|
onSearchSuggestions?: (query: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailInput: FC<EmailInputProps> = ({
|
||||||
|
toEmails,
|
||||||
|
onAddEmail,
|
||||||
|
onRemoveEmail,
|
||||||
|
suggestions,
|
||||||
|
onSearchSuggestions
|
||||||
|
}) => {
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<EmailSuggestion[]>([])
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1)
|
||||||
|
|
||||||
|
const typingTimeoutRef = useRef<number | null>(null)
|
||||||
|
const searchTimeoutRef = useRef<number | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const isValidEmail = (email: string) => {
|
||||||
|
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||||||
|
return emailRegex.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterSuggestions = (input: string) => {
|
||||||
|
if (!input.trim()) {
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
setShowSuggestions(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always search via API for fresh results
|
||||||
|
if (onSearchSuggestions) {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
searchTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
onSearchSuggestions(input)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filtered suggestions when API suggestions change
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(suggestion =>
|
||||||
|
!toEmails.includes(suggestion.address)
|
||||||
|
)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
setFilteredSuggestions(filtered)
|
||||||
|
setShowSuggestions(filtered.length > 0 && to.trim().length > 0)
|
||||||
|
} else if (to.trim() === '') {
|
||||||
|
// Clear suggestions when input is empty
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
setShowSuggestions(false)
|
||||||
|
}
|
||||||
|
}, [suggestions, to, toEmails])
|
||||||
|
|
||||||
|
const selectSuggestion = (suggestion: EmailSuggestion) => {
|
||||||
|
onAddEmail(suggestion.address)
|
||||||
|
setTo('')
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEmail = (email: string) => {
|
||||||
|
if (isValidEmail(email.trim())) {
|
||||||
|
onAddEmail(email.trim())
|
||||||
|
setTo('')
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value
|
||||||
|
setTo(newValue)
|
||||||
|
filterSuggestions(newValue)
|
||||||
|
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue.trim()) {
|
||||||
|
typingTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
if (isValidEmail(newValue.trim())) {
|
||||||
|
addEmail(newValue.trim())
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.trim() && isValidEmail(to.trim())) {
|
||||||
|
addEmail(to.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToFocus = () => {
|
||||||
|
if (to.trim()) {
|
||||||
|
filterSuggestions(to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (showSuggestions && filteredSuggestions.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedSuggestionIndex(prev =>
|
||||||
|
prev < filteredSuggestions.length - 1 ? prev + 1 : 0
|
||||||
|
)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedSuggestionIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : filteredSuggestions.length - 1
|
||||||
|
)
|
||||||
|
} else if (e.key === 'Enter' && selectedSuggestionIndex >= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
selectSuggestion(filteredSuggestions[selectedSuggestionIndex])
|
||||||
|
return
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && to.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current)
|
||||||
|
}
|
||||||
|
addEmail(to.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current)
|
||||||
|
}
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full relative'>
|
||||||
|
<div className="w-full bg-white min-h-10 text-black flex flex-wrap items-center gap-1 px-4 py-2 text-xs rounded-xl border border-border">
|
||||||
|
{toEmails.map((email, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1 bg-[#EBEDF5] text-xs h-7 rounded-full px-3"
|
||||||
|
>
|
||||||
|
<span>{email}</span>
|
||||||
|
<CloseCircle
|
||||||
|
size={16}
|
||||||
|
color='#D52903'
|
||||||
|
className='cursor-pointer'
|
||||||
|
onClick={() => onRemoveEmail(email)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={to}
|
||||||
|
onChange={handleToChange}
|
||||||
|
onBlur={handleToBlur}
|
||||||
|
onFocus={handleToFocus}
|
||||||
|
onKeyDown={handleToKeyDown}
|
||||||
|
placeholder={toEmails.length === 0 ? "ایمیل را وارد کرده و Enter بزنید" : ""}
|
||||||
|
className="flex-1 min-w-32 bg-transparent outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg z-[10000] mt-1">
|
||||||
|
{filteredSuggestions.map((suggestion, index) => (
|
||||||
|
<div
|
||||||
|
key={suggestion.address}
|
||||||
|
className={`px-4 py-2 cursor-pointer text-sm hover:bg-gray-100 border-b border-gray-100 last:border-b-0 ${selectedSuggestionIndex === index ? 'bg-blue-50 text-blue-600' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
onMouseDown={() => selectSuggestion(suggestion)}
|
||||||
|
onMouseEnter={() => setSelectedSuggestionIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{suggestion.name || suggestion.address}</span>
|
||||||
|
{suggestion.name && (
|
||||||
|
<span className="text-xs text-gray-500">{suggestion.address}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailInput
|
||||||
@@ -1,77 +1,56 @@
|
|||||||
import { FC, useState, useEffect, useRef } from 'react'
|
import { FC, useState, useEffect } from 'react'
|
||||||
import { CloseCircle } from 'iconsax-react'
|
import { CloseCircle } from 'iconsax-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSharedStore } from '@/shared/store/sharedStore'
|
import { useSharedStore } from '@/shared/store/sharedStore'
|
||||||
import { useAuthStore } from '../../pages/auth/store/AuthStore'
|
import Input from '@/components/Input'
|
||||||
import { useGetMessageDetail, useSendEmail } from '../../pages/received/hooks/useEmailData'
|
import EmailInput from './EmailInput'
|
||||||
import { SendEmailDto } from '../../pages/received/types/Types'
|
import EmailEditor from './EmailEditor'
|
||||||
import { useUpdateDraft, useSendDraft } from '../../pages/draft/hooks/useDraftData'
|
import EmailAttachments from './EmailAttachments'
|
||||||
import Input from '../Input'
|
import EmailFormActions from './EmailFormActions'
|
||||||
import ReactQuill from 'react-quill-new';
|
import { useNewMessage } from './hooks/useNewMessage'
|
||||||
import Button from '../Button'
|
|
||||||
import Select from '../Select'
|
|
||||||
import UploadButton from '../UploadButton'
|
|
||||||
import { toast } from '../Toast'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const NewMessage: FC = () => {
|
const NewMessage: FC = () => {
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
const [value, setValue] = useState<string>('');
|
const { openNewMessage, setOpenNewMessage } = useSharedStore()
|
||||||
const { setOpenNewMessage, openNewMessage, draftData, setDraftData, editingDraftId, setEditingDraftId } = useSharedStore()
|
|
||||||
const { email: userEmail } = useAuthStore()
|
|
||||||
const [isClosing, setIsClosing] = useState(false)
|
const [isClosing, setIsClosing] = useState(false)
|
||||||
const [shouldRender, setShouldRender] = useState(false)
|
const [shouldRender, setShouldRender] = useState(false)
|
||||||
const [isOpening, setIsOpening] = useState(false)
|
const [isOpening, setIsOpening] = useState(false)
|
||||||
|
|
||||||
// Form state
|
const {
|
||||||
const [to, setTo] = useState('')
|
// Form state
|
||||||
const [toEmails, setToEmails] = useState<string[]>([])
|
toEmails,
|
||||||
const [subject, setSubject] = useState('')
|
subject,
|
||||||
const [priority, setPriority] = useState('')
|
content,
|
||||||
|
priority,
|
||||||
|
attachments,
|
||||||
|
emailSuggestions,
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
const [isSending, setIsSending] = useState(false)
|
isSending,
|
||||||
const [isSavingDraft, setIsSavingDraft] = useState(false)
|
isSavingDraft,
|
||||||
|
|
||||||
// Auto-add email functionality
|
// Form handlers
|
||||||
const typingTimeoutRef = useRef<number | null>(null)
|
addEmail,
|
||||||
|
removeEmail,
|
||||||
|
setSubject,
|
||||||
|
setContent,
|
||||||
|
setPriority,
|
||||||
|
setAttachments,
|
||||||
|
|
||||||
// API hooks
|
// Search functions
|
||||||
const sendEmailMutation = useSendEmail()
|
searchEmailSuggestions,
|
||||||
const updateDraftMutation = useUpdateDraft()
|
|
||||||
const sendDraftMutation = useSendDraft()
|
|
||||||
|
|
||||||
// Get draft detail if draftData exists
|
// Actions
|
||||||
const { data: draftDetail } = useGetMessageDetail(
|
handleSend,
|
||||||
draftData?.id.toString() || '',
|
handleSaveDraft,
|
||||||
draftData?.mailbox || '',
|
resetForm
|
||||||
)
|
} = useNewMessage()
|
||||||
|
|
||||||
// Load draft data into form when draft detail is available
|
|
||||||
useEffect(() => {
|
|
||||||
if (draftDetail) {
|
|
||||||
// Extract emails from to array
|
|
||||||
const toEmailsList = draftDetail.to?.map(recipient => recipient.address) || []
|
|
||||||
setToEmails(toEmailsList)
|
|
||||||
setSubject(draftDetail.subject || '')
|
|
||||||
|
|
||||||
// Try to load HTML content if available, fallback to text or intro
|
|
||||||
const draftWithContent = draftDetail as unknown as { html?: string; text?: string; intro: string }
|
|
||||||
const content = ensureString(draftWithContent.html || draftWithContent.text || draftWithContent.intro)
|
|
||||||
setValue(content)
|
|
||||||
|
|
||||||
// Clear draft data after loading
|
|
||||||
setDraftData(null)
|
|
||||||
}
|
|
||||||
}, [draftDetail, setDraftData])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openNewMessage) {
|
if (openNewMessage) {
|
||||||
setShouldRender(true)
|
setShouldRender(true)
|
||||||
setIsClosing(false)
|
setIsClosing(false)
|
||||||
setIsOpening(true)
|
setIsOpening(true)
|
||||||
// شروع انیمیشن باز شدن بعد از رندر
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsOpening(false)
|
setIsOpening(false)
|
||||||
}, 50)
|
}, 50)
|
||||||
@@ -90,207 +69,25 @@ const NewMessage: FC = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setOpenNewMessage(false)
|
setOpenNewMessage(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
setDraftData(null)
|
|
||||||
setEditingDraftId(null)
|
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseEmailAddresses = (emails: string[]) => {
|
const handleSendWithClose = async () => {
|
||||||
return emails
|
const success = await handleSend()
|
||||||
.filter(email => email.length > 0)
|
if (success) {
|
||||||
.map(email => ({ address: email }))
|
handleClose()
|
||||||
}
|
|
||||||
|
|
||||||
const isValidEmail = (email: string) => {
|
|
||||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
|
||||||
return emailRegex.test(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureString = (val: unknown): string => {
|
|
||||||
if (typeof val === 'string') return val
|
|
||||||
if (val === null || val === undefined) return ''
|
|
||||||
return String(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addEmailToList = (email: string) => {
|
|
||||||
if (!isValidEmail(email)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toEmails.includes(email)) {
|
|
||||||
toast('این ایمیل قبلاً اضافه شده است', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setToEmails([...toEmails, email])
|
|
||||||
setTo('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newValue = e.target.value
|
|
||||||
setTo(newValue)
|
|
||||||
|
|
||||||
// Clear existing timeout
|
|
||||||
if (typingTimeoutRef.current) {
|
|
||||||
clearTimeout(typingTimeoutRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set new timeout to auto-add email after user stops typing
|
|
||||||
if (newValue.trim()) {
|
|
||||||
typingTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
if (isValidEmail(newValue.trim())) {
|
|
||||||
addEmailToList(newValue.trim())
|
|
||||||
}
|
|
||||||
}, 1500) // Wait 1.5 seconds after user stops typing
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToBlur = () => {
|
const handleSaveDraftWithClose = async () => {
|
||||||
// Clear timeout when input loses focus
|
const success = await handleSaveDraft()
|
||||||
if (typingTimeoutRef.current) {
|
if (success) {
|
||||||
clearTimeout(typingTimeoutRef.current)
|
handleClose()
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-add email if valid when input loses focus
|
|
||||||
if (to.trim() && isValidEmail(to.trim())) {
|
|
||||||
addEmailToList(to.trim())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter' && to.trim()) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
// Clear timeout when Enter is pressed
|
|
||||||
if (typingTimeoutRef.current) {
|
|
||||||
clearTimeout(typingTimeoutRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
addEmailToList(to.trim())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up timeout on component unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (typingTimeoutRef.current) {
|
|
||||||
clearTimeout(typingTimeoutRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeEmail = (emailToRemove: string) => {
|
|
||||||
setToEmails(toEmails.filter(email => email !== emailToRemove))
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setTo('')
|
|
||||||
setToEmails([])
|
|
||||||
setSubject('')
|
|
||||||
setValue('')
|
|
||||||
setPriority('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEmailAction = async (isDraft: boolean = false) => {
|
|
||||||
const isEditingDraft = !!editingDraftId
|
|
||||||
|
|
||||||
if (!isDraft && (toEmails.length === 0 || !subject)) {
|
|
||||||
toast('لطفا تمام فیلدهای مورد نیاز را پر کنید', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDraft && toEmails.length === 0 && !subject && ensureString(value).trim() === '') {
|
|
||||||
toast('حداقل یکی از فیلدها باید پر باشد', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set appropriate loading state
|
|
||||||
if (isDraft) {
|
|
||||||
setIsSavingDraft(true)
|
|
||||||
} else {
|
|
||||||
setIsSending(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let messageId = ''
|
|
||||||
if (isEditingDraft) {
|
|
||||||
// We're editing an existing draft
|
|
||||||
const draftUpdateData = {
|
|
||||||
to: parseEmailAddresses(toEmails),
|
|
||||||
subject,
|
|
||||||
html: ensureString(value),
|
|
||||||
text: ensureString(value).replace(/<[^>]*>/g, ''),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDraft) {
|
|
||||||
// Just update the draft
|
|
||||||
await updateDraftMutation.mutateAsync({
|
|
||||||
messageId: editingDraftId.toString(),
|
|
||||||
draftData: draftUpdateData
|
|
||||||
}, {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onSuccess: (data: any) => {
|
|
||||||
messageId = data.data?.messageId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Update first, then send
|
|
||||||
await updateDraftMutation.mutateAsync({
|
|
||||||
messageId: editingDraftId.toString(),
|
|
||||||
draftData: {
|
|
||||||
...draftUpdateData,
|
|
||||||
isDraft: true,
|
|
||||||
uploadOnly: true
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onSuccess: (data: any) => {
|
|
||||||
messageId = data.data?.messageId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await sendDraftMutation.mutateAsync(messageId || editingDraftId.toString())
|
|
||||||
toast('پیام با موفقیت ارسال شد', 'success')
|
|
||||||
}
|
|
||||||
handleClose()
|
|
||||||
} else {
|
|
||||||
// Creating new email/draft
|
|
||||||
const emailData: SendEmailDto = {
|
|
||||||
from: { address: userEmail || 'sender@example.com' },
|
|
||||||
to: parseEmailAddresses(toEmails),
|
|
||||||
subject,
|
|
||||||
html: ensureString(value),
|
|
||||||
text: ensureString(value).replace(/<[^>]*>/g, ''),
|
|
||||||
...(isDraft && { isDraft: true, uploadOnly: true }),
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendEmailMutation.mutateAsync(emailData, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast(data.message, 'success')
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in email action:', error)
|
|
||||||
toast('خطا در انجام عملیات', 'error')
|
|
||||||
} finally {
|
|
||||||
// Reset loading states
|
|
||||||
setIsSending(false)
|
|
||||||
setIsSavingDraft(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSend = () => handleEmailAction(false)
|
|
||||||
const handleSaveDraft = () => handleEmailAction(true)
|
|
||||||
|
|
||||||
const priorityOptions = [
|
|
||||||
{ value: 'high', label: 'بالا' },
|
|
||||||
{ value: 'medium', label: 'متوسط' },
|
|
||||||
{ value: 'low', label: 'پایین' },
|
|
||||||
]
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
{shouldRender && (
|
{shouldRender && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -316,34 +113,14 @@ const NewMessage: FC = () => {
|
|||||||
<label className='text-sm'>
|
<label className='text-sm'>
|
||||||
{t('new_message.to')}
|
{t('new_message.to')}
|
||||||
</label>
|
</label>
|
||||||
<div className='w-full relative mt-1'>
|
<div className='mt-1'>
|
||||||
<div className="w-full bg-white min-h-10 text-black flex flex-wrap items-center gap-1 px-4 py-2 text-xs rounded-xl border border-border">
|
<EmailInput
|
||||||
{/* Email Tags */}
|
toEmails={toEmails}
|
||||||
{toEmails.map((email, index) => (
|
onAddEmail={addEmail}
|
||||||
<div
|
onRemoveEmail={removeEmail}
|
||||||
key={index}
|
suggestions={emailSuggestions}
|
||||||
className="flex items-center gap-1 bg-[#EBEDF5] text-xs h-7 rounded-full px-3"
|
onSearchSuggestions={searchEmailSuggestions}
|
||||||
>
|
/>
|
||||||
<span>{email}</span>
|
|
||||||
<CloseCircle
|
|
||||||
size={16}
|
|
||||||
color='#D52903'
|
|
||||||
className='cursor-pointer'
|
|
||||||
onClick={() => removeEmail(email)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Input Field */}
|
|
||||||
<input
|
|
||||||
value={to}
|
|
||||||
onChange={handleToChange}
|
|
||||||
onBlur={handleToBlur}
|
|
||||||
onKeyDown={handleToKeyDown}
|
|
||||||
placeholder={toEmails.length === 0 ? "ایمیل را وارد کرده و Enter بزنید" : ""}
|
|
||||||
className="flex-1 min-w-32 bg-transparent outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,60 +131,29 @@ const NewMessage: FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ReactQuill
|
<EmailEditor
|
||||||
modules={{
|
value={content}
|
||||||
toolbar: [
|
onChange={setContent}
|
||||||
[{ header: '1' }, { header: '2' }, { font: [] }],
|
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
|
||||||
['bold', 'italic', 'underline'],
|
|
||||||
['link', 'image'],
|
|
||||||
[{ align: [] }],
|
|
||||||
['clean']
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
theme="snow"
|
|
||||||
value={value}
|
|
||||||
onChange={setValue}
|
|
||||||
style={{ minHeight: '120px' }}
|
|
||||||
className='text-sm'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className=''>
|
<EmailAttachments
|
||||||
<UploadButton />
|
attachments={attachments}
|
||||||
</div>
|
onAttachmentsChange={setAttachments}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='flex flex-col gap-4 sm:flex-row pt-4'>
|
<EmailFormActions
|
||||||
<div>
|
priority={priority}
|
||||||
<Select
|
onPriorityChange={setPriority}
|
||||||
items={priorityOptions}
|
onSend={handleSendWithClose}
|
||||||
placeholder={t('new_message.select_priority')}
|
onSaveDraft={handleSaveDraftWithClose}
|
||||||
className='xl:w-[190px] min-w-[130px]'
|
isSending={isSending}
|
||||||
value={priority}
|
isSavingDraft={isSavingDraft}
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 justify-end flex gap-3'>
|
|
||||||
<Button
|
|
||||||
className='!w-full sm:!w-fit px-6 md:px-10 bg-white text-black border border-primary order-2 sm:order-1'
|
|
||||||
label={t('new_message.draft')}
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
loading={isSavingDraft}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className='w-full sm:w-fit px-6 md:px-10 order-1 sm:order-2'
|
|
||||||
label={t('new_message.send')}
|
|
||||||
onClick={handleSend}
|
|
||||||
loading={isSending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { EmailSuggestionsService } from "@/services/EmailSuggestionsService";
|
||||||
|
|
||||||
|
export const useEmailSuggestions = () => {
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
|
|
||||||
|
const { data: suggestionsData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["emailSuggestions", debouncedQuery],
|
||||||
|
queryFn: () =>
|
||||||
|
EmailSuggestionsService.getSuggestions({
|
||||||
|
query: debouncedQuery,
|
||||||
|
limit: 5,
|
||||||
|
}),
|
||||||
|
enabled: debouncedQuery.length >= 2,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("🔥 Query error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchSuggestions = (query: string) => {
|
||||||
|
// Debounce the actual search
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions = suggestionsData?.suggestions || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
searchSuggestions,
|
||||||
|
isLoading,
|
||||||
|
hasResults: suggestions.length > 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSharedStore } from "@/shared/store/sharedStore";
|
||||||
|
import { useAuthStore } from "@/pages/auth/store/AuthStore";
|
||||||
|
import {
|
||||||
|
useGetMessageDetail,
|
||||||
|
useSendEmail,
|
||||||
|
} from "@/pages/received/hooks/useEmailData";
|
||||||
|
import { SendEmailDto, EmailAttachmentDto } from "@/pages/received/types/Types";
|
||||||
|
import { useUpdateDraft, useSendDraft } from "@/pages/draft/hooks/useDraftData";
|
||||||
|
import { toast } from "@/components/Toast";
|
||||||
|
import { useEmailSuggestions } from "./useEmailSuggestions";
|
||||||
|
|
||||||
|
export const useNewMessage = () => {
|
||||||
|
const {
|
||||||
|
draftData,
|
||||||
|
setDraftData,
|
||||||
|
editingDraftId,
|
||||||
|
setEditingDraftId,
|
||||||
|
} = useSharedStore();
|
||||||
|
const { email: userEmail } = useAuthStore();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [toEmails, setToEmails] = useState<string[]>([]);
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [priority, setPriority] = useState("");
|
||||||
|
const [attachments, setAttachments] = useState<EmailAttachmentDto[]>([]);
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
||||||
|
|
||||||
|
// API hooks
|
||||||
|
const sendEmailMutation = useSendEmail();
|
||||||
|
const updateDraftMutation = useUpdateDraft();
|
||||||
|
const sendDraftMutation = useSendDraft();
|
||||||
|
|
||||||
|
// Email suggestions
|
||||||
|
const {
|
||||||
|
suggestions: emailSuggestions,
|
||||||
|
searchSuggestions,
|
||||||
|
} = useEmailSuggestions();
|
||||||
|
|
||||||
|
// Get draft detail if draftData exists
|
||||||
|
const { data: draftDetail } = useGetMessageDetail(
|
||||||
|
draftData?.id.toString() || "",
|
||||||
|
draftData?.mailbox || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load draft data into form
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftDetail) {
|
||||||
|
const toEmailsList =
|
||||||
|
draftDetail.to?.map((recipient) => recipient.address) || [];
|
||||||
|
setToEmails(toEmailsList);
|
||||||
|
setSubject(draftDetail.subject || "");
|
||||||
|
|
||||||
|
const draftWithContent = (draftDetail as unknown) as {
|
||||||
|
html?: string;
|
||||||
|
text?: string;
|
||||||
|
intro: string;
|
||||||
|
};
|
||||||
|
const draftContent = ensureString(
|
||||||
|
draftWithContent.html || draftWithContent.text || draftWithContent.intro
|
||||||
|
);
|
||||||
|
setContent(draftContent);
|
||||||
|
|
||||||
|
setDraftData(null);
|
||||||
|
}
|
||||||
|
}, [draftDetail, setDraftData]);
|
||||||
|
|
||||||
|
const ensureString = (val: unknown): string => {
|
||||||
|
if (typeof val === "string") return val;
|
||||||
|
if (val === null || val === undefined) return "";
|
||||||
|
return String(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidEmail = (email: string) => {
|
||||||
|
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to search email suggestions
|
||||||
|
const searchEmailSuggestions = (query: string) => {
|
||||||
|
searchSuggestions(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEmail = (email: string) => {
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toEmails.includes(email)) {
|
||||||
|
toast("این ایمیل قبلاً اضافه شده است", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToEmails([...toEmails, email]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEmail = (emailToRemove: string) => {
|
||||||
|
setToEmails(toEmails.filter((email) => email !== emailToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseEmailAddresses = (emails: string[]) => {
|
||||||
|
return emails
|
||||||
|
.filter((email) => email.length > 0)
|
||||||
|
.map((email) => ({ address: email }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setToEmails([]);
|
||||||
|
setSubject("");
|
||||||
|
setContent("");
|
||||||
|
setPriority("");
|
||||||
|
setAttachments([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailAction = async (isDraft: boolean = false) => {
|
||||||
|
const isEditingDraft = !!editingDraftId;
|
||||||
|
|
||||||
|
if (!isDraft && (toEmails.length === 0 || !subject)) {
|
||||||
|
toast("لطفا تمام فیلدهای مورد نیاز را پر کنید", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isDraft &&
|
||||||
|
toEmails.length === 0 &&
|
||||||
|
!subject &&
|
||||||
|
ensureString(content).trim() === ""
|
||||||
|
) {
|
||||||
|
toast("حداقل یکی از فیلدها باید پر باشد", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDraft) {
|
||||||
|
setIsSavingDraft(true);
|
||||||
|
} else {
|
||||||
|
setIsSending(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let messageId = "";
|
||||||
|
if (isEditingDraft) {
|
||||||
|
const draftUpdateData = {
|
||||||
|
to: parseEmailAddresses(toEmails),
|
||||||
|
subject,
|
||||||
|
html: ensureString(content),
|
||||||
|
text: ensureString(content).replace(/<[^>]*>/g, ""),
|
||||||
|
...(attachments.length > 0 && { attachments }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDraft) {
|
||||||
|
await updateDraftMutation.mutateAsync(
|
||||||
|
{
|
||||||
|
messageId: editingDraftId.toString(),
|
||||||
|
draftData: draftUpdateData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
messageId = data.data?.messageId;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await updateDraftMutation.mutateAsync(
|
||||||
|
{
|
||||||
|
messageId: editingDraftId.toString(),
|
||||||
|
draftData: {
|
||||||
|
...draftUpdateData,
|
||||||
|
isDraft: true,
|
||||||
|
uploadOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
messageId = data.data?.messageId;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await sendDraftMutation.mutateAsync(
|
||||||
|
messageId || editingDraftId.toString()
|
||||||
|
);
|
||||||
|
toast("پیام با موفقیت ارسال شد", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
setEditingDraftId(null);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const emailData: SendEmailDto = {
|
||||||
|
from: { address: userEmail || "sender@example.com" },
|
||||||
|
to: parseEmailAddresses(toEmails),
|
||||||
|
subject,
|
||||||
|
html: ensureString(content),
|
||||||
|
text: ensureString(content).replace(/<[^>]*>/g, ""),
|
||||||
|
...(attachments.length > 0 && { attachments }),
|
||||||
|
...(isDraft && { isDraft: true, uploadOnly: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendEmailMutation.mutateAsync(emailData, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast(data?.data.message, "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in email action:", error);
|
||||||
|
toast("خطا در انجام عملیات", "error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
setIsSavingDraft(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const result = await handleEmailAction(false);
|
||||||
|
return result || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
const result = await handleEmailAction(true);
|
||||||
|
return result || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Form state
|
||||||
|
toEmails,
|
||||||
|
subject,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
attachments,
|
||||||
|
emailSuggestions,
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isSending,
|
||||||
|
isSavingDraft,
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
addEmail,
|
||||||
|
removeEmail,
|
||||||
|
setSubject,
|
||||||
|
setContent,
|
||||||
|
setPriority,
|
||||||
|
setAttachments,
|
||||||
|
|
||||||
|
// Search functions
|
||||||
|
searchEmailSuggestions,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
handleSend,
|
||||||
|
handleSaveDraft,
|
||||||
|
resetForm,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ export const EmailWebSocketProvider: React.FC<EmailWebSocketProviderProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useEmailWebSocketContext = () => {
|
export const useEmailWebSocketContext = () => {
|
||||||
const context = useContext(EmailWebSocketContext);
|
const context = useContext(EmailWebSocketContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import AnswerIcon from '@/assets/images/answer.svg'
|
|||||||
import Header from './Components/Header'
|
import Header from './Components/Header'
|
||||||
import ReactQuill from 'react-quill-new';
|
import ReactQuill from 'react-quill-new';
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
import { useGetMessageDetail, useSendEmail } from './hooks/useEmailData'
|
import { useGetMessageDetail, useSendEmail, useDownloadAttachment } from './hooks/useEmailData'
|
||||||
import { useAuthStore } from '../auth/store/AuthStore'
|
import { useAuthStore } from '../auth/store/AuthStore'
|
||||||
import { SendEmailDto } from './types/Types'
|
import { SendEmailDto } from './types/Types'
|
||||||
import { toast } from '@/components/Toast'
|
import { toast } from '@/components/Toast'
|
||||||
@@ -31,6 +31,7 @@ const DetailEmail: FC = () => {
|
|||||||
// API hooks
|
// API hooks
|
||||||
const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', mailbox || '')
|
const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', mailbox || '')
|
||||||
const sendEmailMutation = useSendEmail()
|
const sendEmailMutation = useSendEmail()
|
||||||
|
const downloadAttachmentMutation = useDownloadAttachment()
|
||||||
// const saveDraftMutation = useSaveDraft()
|
// const saveDraftMutation = useSaveDraft()
|
||||||
|
|
||||||
const priorityOptions = [
|
const priorityOptions = [
|
||||||
@@ -110,6 +111,36 @@ const DetailEmail: FC = () => {
|
|||||||
toast('قابلیت ارسال به دیگران به زودی اضافه خواهد شد', 'info')
|
toast('قابلیت ارسال به دیگران به زودی اضافه خواهد شد', 'info')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadAttachment = async (attachmentId: string, filename: string) => {
|
||||||
|
if (!id) {
|
||||||
|
toast('شناسه پیام یافت نشد', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await downloadAttachmentMutation.mutateAsync({
|
||||||
|
messageId: id,
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
mailbox: mailbox || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename || `attachment-${attachmentId}`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
toast('فایل با موفقیت دانلود شد', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error)
|
||||||
|
toast('خطا در دانلود فایل', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mark message as seen when it's loaded and not already seen
|
// Mark message as seen when it's loaded and not already seen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messageDetail && !messageDetail.seen && id) {
|
if (messageDetail && !messageDetail.seen && id) {
|
||||||
@@ -161,9 +192,14 @@ const DetailEmail: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-6 flex gap-4 items-center'>
|
<div>
|
||||||
<img src={AvatarImage} className='size-10' />
|
<div className='mt-6 flex gap-4 items-center'>
|
||||||
<div className='text-sm'>{messageDetail.from.name || messageDetail.from.address}</div>
|
<img src={AvatarImage} className='size-10' />
|
||||||
|
<div>
|
||||||
|
<div className='text-sm'>{messageDetail.from.name || messageDetail.from.address}</div>
|
||||||
|
<div className='text-xs text-description mt-0.5'>{messageDetail.from?.address}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='xl:mt-8 mt-3 text-[13px] leading-7 font-light'>
|
<div className='xl:mt-8 mt-3 text-[13px] leading-7 font-light'>
|
||||||
@@ -191,13 +227,20 @@ const DetailEmail: FC = () => {
|
|||||||
messageDetail.attachments.map((attachment, index) => (
|
messageDetail.attachments.map((attachment, index) => (
|
||||||
<div key={index} className='bg-[#EBEDF5] text-sm flex gap-2 h-10 rounded-full items-center px-4'>
|
<div key={index} className='bg-[#EBEDF5] text-sm flex gap-2 h-10 rounded-full items-center px-4'>
|
||||||
<span>{attachment.filename || `attachment-${index + 1}`}</span>
|
<span>{attachment.filename || `attachment-${index + 1}`}</span>
|
||||||
<DocumentDownload size={20} color='#0038FF' className='cursor-pointer' />
|
<DocumentDownload
|
||||||
|
size={20}
|
||||||
|
color='#0038FF'
|
||||||
|
className='cursor-pointer hover:opacity-70 transition-opacity'
|
||||||
|
onClick={() => handleDownloadAttachment(
|
||||||
|
attachment.id || `ATT${String(index + 1).padStart(5, '0')}`,
|
||||||
|
attachment.filename || `attachment-${index + 1}`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className='bg-[#EBEDF5] text-sm flex gap-2 h-10 rounded-full items-center px-4'>
|
<div className='bg-[#EBEDF5] text-sm flex gap-2 h-10 rounded-full items-center px-4'>
|
||||||
<span>Lorem Ipsum.pdf</span>
|
<span>فایل ضمیمهای وجود ندارد</span>
|
||||||
<DocumentDownload size={20} color='#0038FF' />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,3 +112,10 @@ export const useRestoreMessage = () => {
|
|||||||
mutationFn: ({ messageId, mailbox }: SingleActionRequest) => api.restoreMessage(messageId, mailbox),
|
mutationFn: ({ messageId, mailbox }: SingleActionRequest) => api.restoreMessage(messageId, mailbox),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDownloadAttachment = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ messageId, attachmentId, mailbox }: { messageId: string; attachmentId: string; mailbox: string }) =>
|
||||||
|
api.downloadAttachment(messageId, attachmentId, mailbox),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -145,3 +145,17 @@ export const markAsUnread = async (
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const downloadAttachment = async (
|
||||||
|
messageId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mailbox: string
|
||||||
|
): Promise<Blob> => {
|
||||||
|
const response = await axios.get(
|
||||||
|
`/email/messages/${messageId}/attachment?attachmentId=${attachmentId}&mailbox=${mailbox}`,
|
||||||
|
{
|
||||||
|
responseType: "blob",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface EmailHeaderDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailAttachmentDto {
|
export interface EmailAttachmentDto {
|
||||||
|
id?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
encoding?: string;
|
encoding?: string;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import axiosInstance from "@/config/axios";
|
||||||
|
|
||||||
|
export interface EmailSuggestion {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
frequency: number;
|
||||||
|
lastUsed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailSuggestionsResponse {
|
||||||
|
suggestions: EmailSuggestion[];
|
||||||
|
total: number;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetEmailSuggestionsParams {
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailSuggestionsService = {
|
||||||
|
async getSuggestions(
|
||||||
|
params?: GetEmailSuggestionsParams
|
||||||
|
): Promise<EmailSuggestionsResponse> {
|
||||||
|
const response = await axiosInstance.get("/email/suggestions", {
|
||||||
|
params: {
|
||||||
|
query: params?.query || "",
|
||||||
|
limit: params?.limit || 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data; // API returns wrapped response
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user