send , receive , detail email

This commit is contained in:
hamid zarghami
2025-07-09 17:00:21 +03:30
parent 72a6eaa156
commit 4258f57662
14 changed files with 926 additions and 97 deletions
+24 -3
View File
@@ -1,4 +1,4 @@
import { FC } from 'react'
import { FC, useEffect, useState } from 'react'
import './assets/fonts/irancell/style.css'
import 'react-quill-new/dist/quill.snow.css';
import 'react-loading-skeleton/dist/skeleton.css'
@@ -12,8 +12,10 @@ import NewMessage from './components/newMessage/NewMessage';
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { IApiErrorRepsonse } from './types/error.types';
import ToastContainer from './components/Toast';
import { getRefreshToken, removeRefreshToken, removeToken, setRefreshToken, setToken } from './config/func';
import { getRefreshToken, getToken, removeRefreshToken, removeToken, setRefreshToken, setToken } from './config/func';
import { refreshToken } from './pages/auth/service/AuthService';
import { Paths } from './utils/Paths';
import Login from './pages/auth/Login';
declare global {
interface Window {
@@ -96,12 +98,31 @@ const queryClient = new QueryClient({
});
const App: FC = () => {
const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking')
useEffect(() => {
const checkLogin = async () => {
const token = await getToken();
if (token) {
setIsLogin('isLogin')
} else {
setIsLogin('isNotLogin')
if (window.location.href.split('auth').length === 1) {
window.location.href = Paths.login
}
}
}
checkLogin();
}, []);
return (
<BrowserRouter>
<I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}>
<Main />
{
isLogin === 'isLogin' ? <Main /> : isLogin === 'isNotLogin' ? <Login /> : null
}
<NewMessage />
<ToastContainer />
</QueryClientProvider>
+1 -1
View File
@@ -36,7 +36,7 @@ const ToastContainer: React.FC = () => {
}, []);
return (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-[99999999999] flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
+162 -4
View File
@@ -2,21 +2,37 @@ import { FC, useState, useEffect } from 'react'
import { CloseCircle } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import { useSharedStore } from '@/shared/store/sharedStore'
import { useAuthStore } from '../../pages/auth/store/AuthStore'
import { useSendEmail, useSaveDraft } from '../../pages/received/hooks/useEmailData'
import { SendEmailDto } from '../../pages/received/types/Types'
import Input from '../Input'
import ReactQuill from 'react-quill-new';
import Button from '../Button'
import Select from '../Select'
import UploadButton from '../UploadButton'
import { toast } from '../Toast'
const NewMessage: FC = () => {
const { t } = useTranslation('global')
const [value, setValue] = useState('');
const { setOpenNewMessage, openNewMessage } = useSharedStore()
const { email: userEmail } = useAuthStore()
const [isClosing, setIsClosing] = useState(false)
const [shouldRender, setShouldRender] = useState(false)
const [isOpening, setIsOpening] = useState(false)
// Form state
const [to, setTo] = useState('')
const [toEmails, setToEmails] = useState<string[]>([])
const [subject, setSubject] = useState('')
const [priority, setPriority] = useState('')
// API hooks
const sendEmailMutation = useSendEmail()
const saveDraftMutation = useSaveDraft()
useEffect(() => {
if (openNewMessage) {
setShouldRender(true)
@@ -42,6 +58,108 @@ const NewMessage: FC = () => {
setOpenNewMessage(false)
}, 300)
}
const parseEmailAddresses = (emails: string[]) => {
return emails
.filter(email => email.length > 0)
.map(email => ({ address: email }))
}
const isValidEmail = (email: string) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailRegex.test(email)
}
const handleToKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && to.trim()) {
e.preventDefault()
const email = to.trim()
if (!isValidEmail(email)) {
toast('لطفا یک ایمیل معتبر وارد کنید', 'error')
return
}
if (toEmails.includes(email)) {
toast('این ایمیل قبلاً اضافه شده است', 'error')
return
}
setToEmails([...toEmails, email])
setTo('')
}
}
const removeEmail = (emailToRemove: string) => {
setToEmails(toEmails.filter(email => email !== emailToRemove))
}
const handleSend = async () => {
if (toEmails.length === 0 || !subject) {
toast('لطفا تمام فیلدهای مورد نیاز را پر کنید', 'error')
return
}
const emailData: SendEmailDto = {
from: { address: userEmail || 'sender@example.com' },
to: parseEmailAddresses(toEmails),
subject,
html: value,
text: value.replace(/<[^>]*>/g, ''), // Strip HTML for text version
}
try {
await sendEmailMutation.mutateAsync(emailData)
toast('ایمیل با موفقیت ارسال شد', 'success')
handleClose()
// Reset form
setTo('')
setToEmails([])
setSubject('')
setValue('')
setPriority('')
} catch {
toast('خطا در ارسال ایمیل', 'error')
}
}
const handleSaveDraft = async () => {
if (toEmails.length === 0 && !subject && !value) {
toast('حداقل یکی از فیلدها باید پر باشد', 'error')
return
}
const emailData: SendEmailDto = {
from: { address: userEmail || 'sender@example.com' },
to: parseEmailAddresses(toEmails),
subject,
html: value,
text: value.replace(/<[^>]*>/g, ''),
isDraft: true,
}
try {
await saveDraftMutation.mutateAsync(emailData)
toast('پیش‌نویس ذخیره شد', 'success')
handleClose()
// Reset form
setTo('')
setToEmails([])
setSubject('')
setValue('')
setPriority('')
} catch {
toast('خطا در ذخیره پیش‌نویس', 'error')
}
}
const priorityOptions = [
{ value: 'high', label: 'بالا' },
{ value: 'medium', label: 'متوسط' },
{ value: 'low', label: 'پایین' },
]
return (
<>
@@ -66,12 +184,44 @@ const NewMessage: FC = () => {
{/* Content */}
<div className='space-y-6 md:space-y-8'>
<Input
label={t('new_message.to')}
/>
<div>
<label className='text-sm'>
{t('new_message.to')}
</label>
<div className='w-full relative 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">
{/* Email Tags */}
{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={() => removeEmail(email)}
/>
</div>
))}
{/* Input Field */}
<input
value={to}
onChange={(e) => setTo(e.target.value)}
onKeyDown={handleToKeyDown}
placeholder={toEmails.length === 0 ? "ایمیل را وارد کرده و Enter بزنید" : ""}
className="flex-1 min-w-32 bg-transparent outline-none"
/>
</div>
</div>
</div>
<Input
label={t('new_message.subject')}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
<div>
@@ -101,19 +251,25 @@ const NewMessage: FC = () => {
<div className='flex flex-col gap-4 sm:flex-row pt-4'>
<div>
<Select
items={[]}
items={priorityOptions}
placeholder={t('new_message.select_priority')}
className='xl:w-[190px] min-w-[130px]'
value={priority}
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={saveDraftMutation.isPending}
/>
<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={sendEmailMutation.isPending}
/>
</div>
</div>
@@ -121,6 +277,8 @@ const NewMessage: FC = () => {
</div>
</>
)}
</>
)
}
+11
View File
@@ -69,3 +69,14 @@ export const getRedirectUrl = () => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get("redirect");
};
export const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
+1 -1
View File
@@ -67,7 +67,7 @@ textarea::placeholder {
min-height: 40px;
background-color: white;
border-radius: 12px !important;
border: 1px solid #d0d0d0 !important;
border: 1px solid oklch(0.922 0 0) !important;
font-size: 12px !important;
width: 100% !important;
margin: 0px;
+47 -1
View File
@@ -18,7 +18,8 @@
"search": "جستجو"
},
"errors": {
"required": "این فیلد اجباری است"
"required": "این فیلد اجباری است",
"invalid_email": "فرمت ایمیل نامعتبر است"
},
"received": {
"title": "دریافتی ها",
@@ -161,5 +162,50 @@
"answer": "پاسخ",
"forward": "ارسال به دیگران",
"message": "متن پبام"
},
"auth": {
"welcome": "خوش آمدید",
"enter_information": "برای ثبت نام اطلاعات خود را وارد کنید.",
"user_info": "اطلاعات کاربر",
"name": "نام",
"enter_name": "نام خود را وارد کنید",
"family": "نام خانوادگی",
"enter_family": "نام خانوادگی خود را وارد کنید",
"select_date": "انتخاب تاریخ",
"dateofbirth": "تاریخ تولد",
"national_code": "کد ملی",
"national_code_enter": "کد ملی خود را وارد کنید",
"email": "ایمیل",
"enter_email": "ایمیل خود را وارد کنید",
"phonen_number": "شماره تماس",
"enter_phone_number": "شماره تماس خود را وارد کنید",
"password": "رمز عبور",
"enter_password": "رمز عبور انتخابی خود را وارد کنید",
"next": "ادامه",
"before_registered": "آیا قبلا ثبت نام کردید؟",
"login": "ورود",
"enter_info_login": "لطفا اطلاعات خود را وارد کنید.",
"mobile_or_email": " شماره موبایل یا ایمیل",
"enter_mobile_or_email": " شماره موبایل یا ایمیل خود را وارد کنید",
"have_account": "آیا حساب کاربری ندارید؟",
"register": "ثبت نام",
"enter_otp": "کد تایید را وارد کنید",
"verfify_code_text_1": "کد تایید برای ",
"verfify_code_text_2": "ارسال شد.",
"change_number": "تغییر شماره",
"verify_code": "کد تایید ",
"enter_verify_code": "کد تایید ارسالی را وارد کنید ",
"counter_otp": "مانده تا دریافت مجدد کد",
"login_with_password": "ورود با رمز عبور",
"enter_password_step3": "رمز عبور را وارد کنید",
"enter_your_password": "رمز عبور خود را وارد کنید.",
"forgot_password": "فراموشی رمز عبور",
"login_with_otp": "ورود با رمز عبور یکبار مصرف",
"otp_sent": "کد تایید ارسال شد",
"mobile_number": "شماره موبایل",
"enter_mobile_number": "شماره موبایل خود را وارد کنید",
"try_again": "ارسال مجدد",
"toast_resend_code": "کد جدیدی برای شما ارسال شد",
"old_version": "نسخه قدیمی داشبورد"
}
}
+99 -22
View File
@@ -1,40 +1,117 @@
import { FC } from 'react'
import LogoImage from '../../assets/images/logo.svg'
import LogoSmallImage from '../../assets/images/logo-small.svg'
import { useAuthStore } from './store/AuthStore'
import LoginStep1 from './components/LoginStep1'
import LoginStep2 from './components/LoginStep2'
import LoginStep3 from './components/LoginStep3'
import Input from '../../components/Input'
import { useTranslation } from 'react-i18next'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import Error from '../../components/Error'
import Button from '../../components/Button'
import { useLoginWithPassword } from './hooks/useAuthData'
import { ErrorType } from '../../helpers/types'
import { setRefreshToken, setToken, getRedirectUrl } from '../../config/func'
import { toast } from '../../components/Toast'
import { Paths } from '../../utils/Paths'
type LoginFormType = {
email: string;
password: string;
}
const Login: FC = () => {
const { t } = useTranslation('global')
const loginWithPassword = useLoginWithPassword()
const { stepLogin, phone, email } = useAuthStore()
const formik = useFormik<LoginFormType>({
initialValues: {
email: '',
password: ''
},
validationSchema: Yup.object({
email: Yup.string()
.email(t('errors.invalid_email'))
.required(t('errors.required')),
password: Yup.string()
.required(t('errors.required'))
}),
onSubmit(values) {
loginWithPassword.mutate(values, {
onSuccess(data) {
setToken(data?.data?.accessToken?.token)
setRefreshToken(data?.data?.refreshToken?.token)
const redirectUrl = getRedirectUrl();
if (redirectUrl) {
window.location.href = redirectUrl;
return;
}
window.location.href = Paths.received
},
onError(error: ErrorType) {
toast(error?.response?.data?.error?.message[0], 'error')
},
})
},
})
return (
<div className='w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4'>
<div className='flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden'>
<div className='flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7'>
<div className='flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7'>
<div className='flex-1 h-full flex flex-col'>
<div className='flex relative flex-1 flex-col h-full justify-center'>
<img src={LogoSmallImage} className='w-8 hidden xl:block' />
<img src={LogoImage} className='w-44 right-0 left-0 mx-auto absolute top-10 xl:hidden' />
{
stepLogin === 1 ?
<LoginStep1 />
:
stepLogin === 2 && !!phone ?
<LoginStep2 />
: stepLogin === 2 && !!email ?
<LoginStep3 />
: stepLogin === 3 ?
<LoginStep3 />
: null
}
<div>
<div className='mt-5'>
<h2 className='text-2xl font-bold'>
{t('auth.welcome')}
</h2>
<p className='text-description text-sm mt-2'>
{t('auth.enter_info_login')}
</p>
</div>
<div className='mt-16 space-y-6'>
<div>
<Input
label={t('auth.email')}
placeholder={t('auth.enter_email')}
type='email'
className='text-right'
name='email'
onChange={formik.handleChange}
value={formik.values.email}
/>
{formik.touched.email && formik.errors.email && (
<Error errorText={formik.errors.email} />
)}
</div>
<div>
<Input
label={t('auth.password')}
placeholder={t('auth.enter_password')}
type='password'
className='text-right'
name='password'
onChange={formik.handleChange}
value={formik.values.password}
/>
{formik.touched.password && formik.errors.password && (
<Error errorText={formik.errors.password} />
)}
</div>
</div>
<Button
label={t('auth.login')}
className='mt-8'
onClick={() => formik.handleSubmit()}
loading={loginWithPassword.isPending}
/>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from "../types/AuthTypes";
export const loginWithPassword = async (params: LoginWithPasswordType) => {
const { data } = await axios.post(`/auth/login/password`, params);
const { data } = await axios.post(`/auth/login`, params);
return data;
};
+145 -14
View File
@@ -1,6 +1,7 @@
import Button from '@/components/Button'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import AvatarImage from '@/assets/images/avatar.svg'
import { DocumentDownload, EmojiHappy } from 'iconsax-react'
import ForwardIcon from '@/assets/images/forward.svg'
@@ -8,12 +9,125 @@ import AnswerIcon from '@/assets/images/answer.svg'
import Header from './Components/Header'
import ReactQuill from 'react-quill-new';
import Select from '@/components/Select'
import { useGetMessageDetail, useSendEmail, useSaveDraft } from './hooks/useEmailData'
import { useAuthStore } from '../auth/store/AuthStore'
import { SendEmailDto } from './types/Types'
import { toast } from '@/components/Toast'
import { formatDate } from '@/config/func'
const DetailEmail: FC = () => {
const { t } = useTranslation()
const { id } = useParams<{ id: string }>()
const { email: userEmail } = useAuthStore()
const [showAnswer, setShowAnswer] = useState<boolean>(false)
const [value, setValue] = useState<string>('')
const [priority, setPriority] = useState<string>('')
// API hooks
const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', !!id)
const sendEmailMutation = useSendEmail()
const saveDraftMutation = useSaveDraft()
const priorityOptions = [
{ value: 'high', label: 'بالا' },
{ value: 'medium', label: 'متوسط' },
{ value: 'low', label: 'پایین' },
]
const handleSendReply = async () => {
if (!value.trim()) {
toast('لطفا متن پیام را وارد کنید', 'error')
return
}
if (!messageDetail) {
toast('اطلاعات پیام یافت نشد', 'error')
return
}
const replyData: SendEmailDto = {
from: { address: userEmail || 'sender@example.com' },
to: [{ address: messageDetail.from.address, name: messageDetail.from.name }],
subject: `Re: ${messageDetail.subject}`,
html: value,
text: value.replace(/<[^>]*>/g, ''),
reference: {
inReplyTo: messageDetail.messageId,
originalMessageId: messageDetail.id.toString()
}
}
try {
await sendEmailMutation.mutateAsync(replyData)
toast('پاسخ با موفقیت ارسال شد', 'success')
setShowAnswer(false)
setValue('')
setPriority('')
} catch {
toast('خطا در ارسال پاسخ', 'error')
}
}
const handleSaveDraft = async () => {
if (!value.trim()) {
toast('متن پیام خالی است', 'error')
return
}
if (!messageDetail) {
toast('اطلاعات پیام یافت نشد', 'error')
return
}
const draftData: SendEmailDto = {
from: { address: userEmail || 'sender@example.com' },
to: [{ address: messageDetail.from.address, name: messageDetail.from.name }],
subject: `Re: ${messageDetail.subject}`,
html: value,
text: value.replace(/<[^>]*>/g, ''),
isDraft: true,
reference: {
inReplyTo: messageDetail.messageId,
originalMessageId: messageDetail.id.toString()
}
}
try {
await saveDraftMutation.mutateAsync(draftData)
toast('پیش‌نویس ذخیره شد', 'success')
} catch {
toast('خطا در ذخیره پیش‌نویس', 'error')
}
}
const handleForward = () => {
// TODO: Implement forward functionality
toast('قابلیت ارسال به دیگران به زودی اضافه خواهد شد', 'info')
}
if (isLoading) {
return (
<div className='bg-white rounded-4xl p-8'>
<div className='animate-pulse'>
<div className='h-6 bg-gray-200 rounded mb-4'></div>
<div className='h-4 bg-gray-200 rounded mb-2'></div>
<div className='h-4 bg-gray-200 rounded mb-2'></div>
<div className='h-20 bg-gray-200 rounded'></div>
</div>
</div>
)
}
if (error || !messageDetail) {
return (
<div className='bg-white rounded-4xl p-8'>
<div className='text-center text-red-500'>
خطا در بارگذاری پیام
</div>
</div>
)
}
return (
<div className='bg-white rounded-4xl p-8'>
@@ -23,7 +137,7 @@ const DetailEmail: FC = () => {
<div className='flex xl:flex-row flex-col-reverse gap-4 xl:items-center'>
<div className='flex gap-1 items-center'>
<div className='font-extralight hidden xl:block'>{t('mail.subject')}</div>
<div className='leading-7 text-sm'>لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ</div>
<div className='leading-7 text-sm'>{messageDetail.subject || 'بدون موضوع'}</div>
</div>
<div className='flex gap-2'>
<Button
@@ -38,28 +152,41 @@ const DetailEmail: FC = () => {
</div>
<div className='flex justify-end text-description xl:text-sm text-[10px]'>
۵ اسفندماه ۱۴۰۳، ساعت ۱۲:۰۰
{formatDate(messageDetail.date)}
</div>
</div>
<div className='mt-6 flex gap-4 items-center'>
<img src={AvatarImage} className='size-10' />
<div className='text-sm'>مهرداد مظفری</div>
<div className='text-sm'>{messageDetail.from.name || messageDetail.from.address}</div>
</div>
<p className='xl:mt-8 mt-3 text-[13px] leading-7 font-light'>
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد کتابهای زیادی در شصت و سه درصد گذشته حال و آیندهلورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد کتابهای زیادی در شصت و سه درصد گذشته حال و آینده
</p>
<div className='xl:mt-8 mt-3 text-[13px] leading-7 font-light'>
{messageDetail.html && messageDetail.html.length > 0 ? (
<div dangerouslySetInnerHTML={{ __html: messageDetail.html.join('') }} />
) : (
<p>{messageDetail.text || 'محتوای پیام'}</p>
)}
</div>
<div className='mt-10 text-sm font-bold'>
{t('mail.attachments')}
</div>
<div className='flex border-b border-border pb-10 mt-4 flex-wrap gap-2.5 items-center'>
<div className='bg-[#EBEDF5] text-sm flex gap-2 h-10 rounded-full items-center px-4'>
<span>Lorem Ipsum.pdf</span>
<DocumentDownload size={20} color='#0038FF' />
</div>
{messageDetail.attachments && messageDetail.attachments.length > 0 ? (
messageDetail.attachments.map((attachment, index) => (
<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>
<DocumentDownload size={20} color='#0038FF' className='cursor-pointer' />
</div>
))
) : (
<div className='bg-[#EBEDF5] text-sm flex gap-2 h-10 rounded-full items-center px-4'>
<span>Lorem Ipsum.pdf</span>
<DocumentDownload size={20} color='#0038FF' />
</div>
)}
</div>
{
@@ -73,7 +200,7 @@ const DetailEmail: FC = () => {
<div>{t('mail.answer')}</div>
</div>
<div className='flex gap-2 cursor-pointer items-center border-r border-border pr-5'>
<div onClick={handleForward} className='flex gap-2 cursor-pointer items-center border-r border-border pr-5'>
<img src={ForwardIcon} className='w-[17px]' />
<div>{t('mail.forward')}</div>
</div>
@@ -108,19 +235,25 @@ const DetailEmail: FC = () => {
<div className='flex flex-col gap-4 sm:flex-row pt-4'>
<div>
<Select
items={[]}
items={priorityOptions}
placeholder={t('new_message.select_priority')}
className='xl:w-[190px]'
value={priority}
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={saveDraftMutation.isPending}
/>
<Button
className='w-full sm:w-fit px-6 md:px-10 order-1 sm:order-2'
label={t('new_message.send')}
onClick={handleSendReply}
loading={sendEmailMutation.isPending}
/>
</div>
</div>
@@ -128,8 +261,6 @@ const DetailEmail: FC = () => {
</div>
</div>
}
</div>
)
}
+56 -46
View File
@@ -3,46 +3,43 @@ import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Table from '../../components/Table';
import { ColumnType } from '../../components/types/TableTypes';
import { InfoCircle, More, Refresh2, Trash, Edit, Archive, ArchiveSlash } from 'iconsax-react';
import { InfoCircle, More, Refresh2, Trash, Edit, Archive } from 'iconsax-react';
import { RowActionItem } from '../../components/RowActionsDropdown';
import { useNavigate } from 'react-router-dom';
interface Message extends Record<string, unknown> {
id: string;
title: string;
sender: string;
date: string;
read: boolean;
}
import { useGetInbox } from './hooks/useEmailData';
import { InboxMessage } from './types/Types';
import { formatDate } from '@/config/func';
const List: FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [isLoading] = useState(false);
const [selectedMessages, setSelectedMessages] = useState<Message[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [filters, setFilters] = useState<FilterValues>({});
const messageData: Message[] = [
{ id: '1', title: 'اولین پیام', sender: 'علی محمدی', date: '1402/05/12', read: true },
{ id: '2', title: 'دومین پیام', sender: 'زهرا احمدی', date: '1402/05/13', read: true },
{ id: '3', title: 'جلسه هفتگی', sender: 'مدیریت', date: '1402/05/14', read: true },
];
const { data: inboxData, isLoading, refetch } = useGetInbox({
page: currentPage,
limit: 10,
...filters
});
const columns: ColumnType<Message>[] = [
const [selectedMessages, setSelectedMessages] = useState<InboxMessage[]>([]);
const columns: ColumnType<InboxMessage>[] = [
{
key: 'title',
key: 'subject',
title: 'عنوان پیام',
render: (item) => (
<div className="flex items-center">
{!item.read && <div className="w-1.5 h-1.5 md:w-2 md:h-2 rounded-full bg-blue-500 mr-2" />}
<span className="truncate">{item.title}</span>
{/* {!item.seen && <div className="w-1.5 h-1.5 md:w-2 md:h-2 rounded-full bg-blue-500 mr-2" />} */}
<span className="truncate">{item.subject || 'بدون موضوع'}</span>
</div>
)
},
{
key: 'sender',
key: 'from',
title: 'فرستنده',
render: (item) => (
<span className="truncate">{item.sender}</span>
<span className="truncate">{item.from.name || item.from.address}</span>
)
},
{
@@ -50,14 +47,19 @@ const List: FC = () => {
title: 'تاریخ',
align: 'center',
render: (item) => (
<span className="text-xs md:text-sm">{item.date}</span>
<span>{formatDate(item.date)}</span>
)
},
];
const tableActions = (
<div className='flex items-center gap-2 md:gap-4'>
<Refresh2 size={18} color='black' className="cursor-pointer" />
<Refresh2
size={18}
color='black'
className="cursor-pointer"
onClick={() => refetch()}
/>
<More size={18} color='black' className='rotate-90 cursor-pointer' />
</div>
);
@@ -71,10 +73,11 @@ const List: FC = () => {
);
const handleFilterChange = (newFilters: FilterValues) => {
console.log('Applied filters:', newFilters);
setFilters(newFilters);
setCurrentPage(1); // Reset to first page when filters change
};
const handleSelectionChange = (selectedRows: Message[]) => {
const handleSelectionChange = (selectedRows: InboxMessage[]) => {
setSelectedMessages(selectedRows);
};
@@ -93,16 +96,16 @@ const List: FC = () => {
// اینجا منطق علامت‌گذاری را پیاده‌سازی کنید
};
const getRowActions = (message: Message): RowActionItem[] => [
const getRowActions = (message: InboxMessage): RowActionItem[] => [
{
label: 'خروج از آرشیو',
icon: <ArchiveSlash size={16} color="black" />,
onClick: () => console.log('خروج از آرشیو', message.id),
label: message.seen ? 'علامت‌گذاری به عنوان خوانده نشده' : 'علامت‌گذاری به عنوان خوانده شده',
icon: <Edit size={16} color="black" />,
onClick: () => console.log('تغییر وضعیت خوانده شده', message.id),
},
{
label: 'خوانده شده',
icon: <Edit size={16} color="black" />,
onClick: () => console.log('علامت‌گذاری به عنوان خوانده شده', message.id),
label: 'بایگانی',
icon: <Archive size={16} color="black" />,
onClick: () => console.log('بایگانی پیام', message.id),
},
{
label: 'حذف',
@@ -111,22 +114,29 @@ const List: FC = () => {
},
];
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const messages = inboxData?.data?.results || [];
const pager = inboxData?.data?.pager;
return (
<div className='mt-2 md:mt-4 px-2 md:px-0'>
<h1 className="text-lg mb-4 md:mb-0">{t('received.title')}</h1>
<Filters
fields={[
{ type: 'date', name: 'from_date', placeholder: t('received.from_date') },
{ type: 'date', name: 'to_date', placeholder: t('received.to_date') },
{ type: 'date', name: 'dateFrom', placeholder: t('received.from_date') },
{ type: 'date', name: 'dateTo', placeholder: t('received.to_date') },
{
type: 'select',
name: 'status',
name: 'isRead',
placeholder: t('received.all'),
options: [
{ value: 'all', label: t('received.all') },
{ value: 'read', label: t('received.read') },
{ value: 'unread', label: t('received.unread') }
{ value: '', label: t('received.all') },
{ value: 'true', label: t('received.read') },
{ value: 'false', label: t('received.unread') }
]
},
{ type: 'input', name: 'search', placeholder: t('received.search') }
@@ -135,9 +145,9 @@ const List: FC = () => {
searchField="search"
/>
<Table<Message>
<Table<InboxMessage>
columns={columns}
data={messageData}
data={messages}
isLoading={isLoading}
showHeader={false}
actions={tableActions}
@@ -152,11 +162,11 @@ const List: FC = () => {
}
selectable={true}
rowActions={getRowActions}
pagination={{
totalPages: 10,
currentPage: 1,
onPageChange: () => { }
}}
pagination={pager ? {
totalPages: pager.totalPages,
currentPage: pager.page,
onPageChange: handlePageChange
} : undefined}
/>
</div>
+45
View File
@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../service/EmailService';
import { SendEmailDto, MessageListQueryDto } from '../types/Types';
export const useSendEmail = () => {
return useMutation({
mutationFn: (variables: SendEmailDto) => api.sendEmail(variables),
});
};
export const useSaveDraft = () => {
return useMutation({
mutationFn: (variables: SendEmailDto) => api.saveDraft(variables),
});
};
export const useGetInbox = (query: MessageListQueryDto) => {
return useQuery({
queryKey: ['inbox', query],
queryFn: () => api.getInbox(query),
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
export const useGetMessageDetail = (messageId: string, enabled: boolean = true) => {
return useQuery({
queryKey: ['message', messageId],
queryFn: () => api.getMessageDetail(messageId),
enabled: !!messageId && enabled,
staleTime: 1000 * 60 * 10, // 10 minutes
});
};
export const useDeleteMessage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (messageId: string) => api.deleteMessage(messageId),
onSuccess: () => {
// Invalidate inbox queries to refresh the list
queryClient.invalidateQueries({ queryKey: ['inbox'] });
},
});
};
@@ -0,0 +1,42 @@
import axios from "../../../config/axios";
import {
SendEmailDto,
MessageListQueryDto,
InboxResponse,
MessageDetailResponse,
MessageDetail,
} from "../types/Types";
export const sendEmail = async (params: SendEmailDto) => {
const { data } = await axios.post(`/email/send`, params);
return data;
};
export const saveDraft = async (params: SendEmailDto) => {
const { data } = await axios.post(`/email/draft`, {
...params,
isDraft: true,
});
return data;
};
export const getInbox = async (
query: MessageListQueryDto
): Promise<InboxResponse> => {
const { data } = await axios.get(`/email/messages/inbox`, { params: query });
return data;
};
export const getMessageDetail = async (
messageId: string
): Promise<MessageDetail> => {
const { data }: { data: MessageDetailResponse } = await axios.get(
`/email/messages/inbox/${messageId}`
);
return data.data.message;
};
export const deleteMessage = async (messageId: string): Promise<void> => {
const { data } = await axios.delete(`/email/messages/inbox/${messageId}`);
return data;
};
+288
View File
@@ -0,0 +1,288 @@
export interface EmailRecipientDto {
name?: string;
address: string;
}
export interface EmailHeaderDto {
key: string;
value: string;
}
export interface EmailAttachmentDto {
filename?: string;
contentType?: string;
encoding?: string;
contentTransferEncoding?: string;
contentDisposition?: "inline" | "attachment";
content: string;
cid?: string;
}
export interface DraftReferenceDto {
mailbox: string;
id: number;
}
export interface EmailEnvelopeDto {
from: EmailRecipientDto;
to: EmailRecipientDto[];
}
export interface SendEmailDto {
mailbox?: string;
from: EmailRecipientDto;
replyTo?: EmailRecipientDto;
to: EmailRecipientDto[];
cc?: EmailRecipientDto[];
bcc?: EmailRecipientDto[];
headers?: EmailHeaderDto[];
subject?: string;
text?: string;
html?: string;
attachments?: EmailAttachmentDto[];
meta?: Record<string, unknown>;
sess?: string;
ip?: string;
reference?: Record<string, unknown>;
isDraft?: boolean;
draft?: DraftReferenceDto;
sendTime?: string;
uploadOnly?: boolean;
envelope?: EmailEnvelopeDto;
}
export interface MessageListQueryDto {
page?: number;
limit?: number;
order?: string;
search?: string;
from?: string;
subject?: string;
dateFrom?: string;
dateTo?: string;
hasAttachment?: boolean;
isRead?: boolean;
isFlagged?: boolean;
}
// Updated types based on actual API response
export interface InboxMessage extends Record<string, unknown> {
id: number;
mailbox: string;
thread: string;
from: {
address: string;
name: string;
};
to: Array<{
address: string;
name: string;
}>;
cc: Array<{
address: string;
name: string;
}>;
bcc: Array<{
address: string;
name: string;
}>;
messageId: string;
subject: string;
date: string;
idate: string;
intro: string;
attachments: boolean;
attachmentsList: EmailAttachmentDto[];
size: number;
seen: boolean;
deleted: boolean;
flagged: boolean;
draft: boolean;
answered: boolean;
forwarded: boolean;
references: string[];
contentType: {
value: string;
params: {
boundary: string;
};
};
}
export interface InboxPager {
page: number;
limit: number;
totalItems: number;
totalPages: number;
prevPage: boolean;
nextPage: boolean;
}
export interface InboxResponse {
statusCode: number;
success: boolean;
data: {
pager: InboxPager;
message: string;
results: InboxMessage[];
};
}
// Legacy types (keeping for backward compatibility)
export interface MessageDto {
id: string;
mailbox: string;
uid: number;
flags: string[];
envelope: {
date: string;
subject: string;
from: EmailRecipientDto[];
to: EmailRecipientDto[];
cc?: EmailRecipientDto[];
bcc?: EmailRecipientDto[];
messageId: string;
inReplyTo?: string;
references?: string[];
};
bodyStructure: Record<string, unknown>;
source?: string;
text?: string;
html?: string;
attachments?: EmailAttachmentDto[];
headers?: EmailHeaderDto[];
size: number;
created: string;
updated: string;
}
export interface MessageListResponse {
messages: MessageDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface UserMailboxMessageParamDto {
messageId: string;
}
// New interface based on actual API response
export interface MessageDetailResponse {
statusCode: number;
success: boolean;
data: {
message: {
success: boolean;
id: number;
mailbox: string;
thread: string;
user: string;
envelope: {
from: string;
rcpt: Array<{
value: string;
formatted: string;
}>;
};
from: {
address: string;
name: string;
};
to: Array<{
address: string;
name: string;
}>;
subject: string;
messageId: string;
date: string;
idate: string;
size: number;
seen: boolean;
deleted: boolean;
flagged: boolean;
draft: boolean;
answered: boolean;
forwarded: boolean;
html: string[];
text: string;
attachments: EmailAttachmentDto[];
references: string[];
metaData: Record<string, unknown>;
verificationResults: {
tls: {
name: string;
standardName: string;
version: string;
};
spf: string;
dkim: string;
arc: boolean;
bimi: boolean;
};
contentType: {
value: string;
params: {
boundary: string;
};
};
};
};
}
export interface MessageDetail {
success: boolean;
id: number;
mailbox: string;
thread: string;
user: string;
envelope: {
from: string;
rcpt: Array<{
value: string;
formatted: string;
}>;
};
from: {
address: string;
name: string;
};
to: Array<{
address: string;
name: string;
}>;
subject: string;
messageId: string;
date: string;
idate: string;
size: number;
seen: boolean;
deleted: boolean;
flagged: boolean;
draft: boolean;
answered: boolean;
forwarded: boolean;
html: string[];
text: string;
attachments: EmailAttachmentDto[];
references: string[];
metaData: Record<string, unknown>;
verificationResults: {
tls: {
name: string;
standardName: string;
version: string;
};
spf: string;
dkim: string;
arc: boolean;
bimi: boolean;
};
contentType: {
value: string;
params: {
boundary: string;
};
};
}