diff --git a/README.md b/README.md index da98444..14bc682 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,14 @@ -# React + TypeScript + Vite +# Mailbox App -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +A modern, right-to-left (RTL) mailbox application built with React, TypeScript, and Tailwind CSS. -Currently, two official plugins are available: +## Features -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- **RTL Support:** Designed for Persian (Farsi) users with full RTL layout. +- **Sidebar Navigation:** Access to Received, Sent, Drafts, Archive, Trash, Favorites, Spam, and Settings. +- **Responsive Design:** Works seamlessly on desktop and mobile. +- **Localization:** Easily translatable, with Farsi as the default language. +- **Custom Theming:** Easily adjust primary and secondary colors via CSS variables. +- **Reusable Components:** Includes customizable Button and Sidebar components. -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config({ - extends: [ - // Remove ...tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config({ - plugins: { - // Add the react-x and react-dom plugins - 'react-x': reactX, - 'react-dom': reactDom, - }, - rules: { - // other rules... - // Enable its recommended typescript rules - ...reactX.configs['recommended-typescript'].rules, - ...reactDom.configs.recommended.rules, - }, -}) -``` +## Project Structure diff --git a/eslint.config.js b/eslint.config.js index 092408a..b1cad5b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,7 +18,8 @@ export default tseslint.config( 'react-refresh': reactRefresh, }, rules: { - ...reactHooks.configs.recommended.rules, + 'react-hooks/rules-of-hooks': 'off', + 'react-hooks/exhaustive-deps': 'off', 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, diff --git a/package.json b/package.json index b8dd95a..53f0148 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,11 @@ "i18next": "^25.0.1", "iconsax-react": "^0.0.8", "react": "^19.0.0", + "react-date-object": "^2.1.9", "react-dom": "^19.0.0", "react-i18next": "^15.5.1", + "react-loading-skeleton": "^3.5.0", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.5.2", "react-spinners": "^0.17.0", "tailwind-merge": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eaa0cc..f882002 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,21 @@ importers: react: specifier: ^19.0.0 version: 19.1.0 + react-date-object: + specifier: ^2.1.9 + version: 2.1.9 react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) react-i18next: specifier: ^15.5.1 version: 15.5.1(i18next@25.0.1(typescript@5.7.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.3) + react-loading-skeleton: + specifier: ^3.5.0 + version: 3.5.0(react@19.1.0) + react-multi-date-picker: + specifier: ^4.5.2 + version: 4.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-router-dom: specifier: ^7.5.2 version: 7.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1166,11 +1175,20 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-date-object@2.1.9: + resolution: {integrity: sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==} + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: react: ^19.1.0 + react-element-popper@2.1.7: + resolution: {integrity: sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-i18next@15.5.1: resolution: {integrity: sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==} peerDependencies: @@ -1190,6 +1208,17 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-loading-skeleton@3.5.0: + resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} + peerDependencies: + react: '>=16.8.0' + + react-multi-date-picker@4.5.2: + resolution: {integrity: sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2408,11 +2437,18 @@ snapshots: queue-microtask@1.2.3: {} + react-date-object@2.1.9: {} + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 scheduler: 0.26.0 + react-element-popper@2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-i18next@15.5.1(i18next@25.0.1(typescript@5.7.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.3): dependencies: '@babel/runtime': 7.27.0 @@ -2425,6 +2461,17 @@ snapshots: react-is@16.13.1: {} + react-loading-skeleton@3.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + + react-multi-date-picker@4.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-date-object: 2.1.9 + react-dom: 19.1.0(react@19.1.0) + react-element-popper: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-refresh@0.17.0: {} react-router-dom@7.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): diff --git a/src/App.tsx b/src/App.tsx index f21863c..17f0852 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { FC } from 'react' import './assets/fonts/irancell/style.css' +import 'react-loading-skeleton/dist/skeleton.css' import { BrowserRouter } from 'react-router-dom' import Main from './shared/Main' import i18next from 'i18next' diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx new file mode 100644 index 0000000..0112e99 --- /dev/null +++ b/src/components/DatePicker.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect, FC } from 'react'; +import DatePicker from 'react-multi-date-picker'; +import persian from 'react-date-object/calendars/persian'; +import persian_fa from 'react-date-object/locales/persian_fa'; +import DateObject from 'react-date-object'; +import { Calendar } from 'iconsax-react'; +import { clx } from '../helpers/utils'; + +type Props = { + onChange: (date: string) => void; + defaultValue?: string; + error_text?: string; + placeholder: string; + reset?: boolean; + isDateTime?: boolean; + className?: string; + label?: string, + readOnly?: boolean; +}; + +const DatePickerComponent: FC = (props: Props) => { + const [value, setValue] = useState(null); + + useEffect(() => { + if (props.reset) { + setValue(null); + } + }, [props.reset]); + + useEffect(() => { + if (value) { + const formattedDate = `${value.year}/${value.month.number}/${value.day}`; + props.onChange(formattedDate); + } + }, [value]); + + useEffect(() => { + if (props.defaultValue && !value) { + const defaultDate = new DateObject({ + date: props.defaultValue, + calendar: persian, + locale: persian_fa, + }); + setValue(defaultDate); + } + }, [props.defaultValue, value]); + + return ( +
+ { + props.label && +
+ {props.label} +
+ } +
+ setValue(date as DateObject)} + calendar={persian} + locale={persian_fa} + calendarPosition="bottom-right" + className={props.className} + readOnly={props.readOnly} + /> + {props.error_text && props.error_text !== '' && ( +
+ {props.error_text} +
+ )} + + +
+
+ ); +}; + +export default DatePickerComponent; \ No newline at end of file diff --git a/src/components/DefaultTableSkeleton.tsx b/src/components/DefaultTableSkeleton.tsx new file mode 100644 index 0000000..c230eb7 --- /dev/null +++ b/src/components/DefaultTableSkeleton.tsx @@ -0,0 +1,30 @@ +import { FC, Fragment } from 'react' +import Td from './Td' +import Skeleton from 'react-loading-skeleton' + +type Props = { + tdCount: number, + trCount?: number, +} + +const DefaultTableSkeleton: FC = ({ tdCount, trCount = 5 }) => { + return ( + + { + Array.from({ length: trCount }).map((_, rowIndex) => ( + + { + Array.from({ length: tdCount }).map((_, colIndex) => ( + + + + )) + } + + )) + } + + ) +} + +export default DefaultTableSkeleton diff --git a/src/components/Filters.tsx b/src/components/Filters.tsx new file mode 100644 index 0000000..5bf20a2 --- /dev/null +++ b/src/components/Filters.tsx @@ -0,0 +1,140 @@ +import { FC, useEffect, useState, ChangeEvent } from 'react'; +import DatePicker from './DatePicker'; +import Input from './Input'; +import Select, { ItemsSelectType } from './Select'; + +// تعریف نوع داده برای فیلدهای مختلف +export type DateFieldType = { + type: 'date'; + name: string; + placeholder: string; + defaultValue?: string; +}; + +export type SelectFieldType = { + type: 'select'; + name: string; + placeholder: string; + options: ItemsSelectType[]; + defaultValue?: string; +}; + +export type InputFieldType = { + type: 'input'; + name: string; + placeholder: string; + defaultValue?: string; +}; + +export type FieldType = DateFieldType | SelectFieldType | InputFieldType; + +// تعریف نوع داده برای مقادیر فیلترها +export type FilterValues = Record; + +interface FiltersProps { + fields: FieldType[]; + onChange: (filters: FilterValues) => void; + initialValues?: FilterValues; + className?: string; + fieldClassName?: string; + searchField?: string; // نام فیلد جستجو که باید در انتها نمایش داده شود +} + +const Filters: FC = ({ + fields, + onChange, + initialValues = {}, + className = "mt-10 flex justify-between items-center", + fieldClassName = "flex gap-4", + searchField = "search" // پیش‌فرض فیلد سرچ با نام search +}) => { + const [filters, setFilters] = useState({}); + + // تنظیم مقادیر اولیه + useEffect(() => { + if (Object.keys(initialValues).length > 0) { + setFilters(initialValues); + } else { + // تنظیم مقادیر پیش‌فرض از فیلدها + const defaultValues: FilterValues = {}; + fields.forEach(field => { + if ('defaultValue' in field && field.defaultValue !== undefined) { + defaultValues[field.name] = field.defaultValue; + } + }); + + if (Object.keys(defaultValues).length > 0) { + setFilters(defaultValues); + onChange(defaultValues); + } + } + }, [fields, initialValues, onChange]); + + const handleChange = (name: string, value: string | null) => { + const newFilters = { ...filters, [name]: value }; + setFilters(newFilters); + onChange(newFilters); + }; + + const handleInputChange = (name: string, event: ChangeEvent) => { + handleChange(name, event.target.value); + }; + + const renderField = (field: FieldType) => { + const currentValue = filters[field.name]; + + switch (field.type) { + case 'date': + return ( + handleChange(field.name, value)} + defaultValue={currentValue || field.defaultValue || ''} + /> + ); + + case 'select': + return ( + handleInputChange(field.name, e)} + /> + ); + } + }; + + // جداسازی فیلد جستجو از سایر فیلدها + const searchFieldObj = fields.find(field => field.name === searchField); + const otherFields = fields.filter(field => field.name !== searchField); + + return ( +
+
+ {otherFields.map(renderField)} +
+ {searchFieldObj && ( +
+ {renderField(searchFieldObj)} +
+ )} +
+ ); +}; + +export default Filters; \ No newline at end of file diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 8822f29..9a3dee0 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -33,9 +33,9 @@ const Input: FC = (props: Props) => { const [search, setSearch] = useState('') const inputClass = clx( - 'w-full h-10 text-black block px-4 text-xs rounded-xl border border-border', + 'w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border', props.readOnly && 'bg-gray-100 border-0 text-description', - props.variant === 'search' && 'bg-[#EEF0F7] border-0 ps-10', + props.variant === 'search' && 'ps-10', props.className ); @@ -84,7 +84,10 @@ const Input: FC = (props: Props) => { {props.label} -
+
{ setSearch(e.target.value) handleInputChange(e) diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 0000000..f1eb7ea --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,65 @@ +import { FC, SelectHTMLAttributes } from 'react' +import { clx } from '../helpers/utils' +import { ArrowDown2 } from 'iconsax-react' + +export type ItemsSelectType = { + value: string, + label: string, +} +type Props = { + className?: string, + items: ItemsSelectType[], + error_text?: string, + placeholder?: string, + label?: string, + readOnly?: boolean, +} & SelectHTMLAttributes + +const Select: FC = (props: Props) => { + return ( +
+ { + props.label && + + } +
+ + +
+ { + props.error_text && props.error_text !== '' ? +
+ {props.error_text} +
+ : null + } +
+ + ) +} + +export default Select \ No newline at end of file diff --git a/src/components/Table.tsx b/src/components/Table.tsx new file mode 100644 index 0000000..b26fdc0 --- /dev/null +++ b/src/components/Table.tsx @@ -0,0 +1,146 @@ +import React, { Fragment, ReactNode } from 'react'; +import DefaultTableSkeleton from './DefaultTableSkeleton'; +import Td from './Td'; + +export type RowDataType = Record & { id: string | number }; + +export interface ColumnType { + title: string; + key: string; + render?: (item: T) => React.ReactNode; + width?: string | number; + align?: 'left' | 'center' | 'right'; + sortable?: boolean; + className?: string; +} + +export interface TableProps { + columns: ColumnType[]; + data: T[]; + isLoading?: boolean; + onRowClick?: (id: T['id'], item: T) => void; + className?: string; + rowClassName?: string | ((item: T, index: number) => string); + noDataMessage?: React.ReactNode; + headerClassName?: string; + emptyRowsCount?: number; + showHeader?: boolean; + actions?: ReactNode; + actionsClassName?: string; + actionsPosition?: 'top' | 'header-replace' | 'above-header'; +} + +const Table = ({ + columns, + data, + isLoading = false, + onRowClick, + className = '', + rowClassName = '', + noDataMessage = 'هیچ داده‌ای یافت نشد', + headerClassName = 'bg-gray-50', + emptyRowsCount = 5, + showHeader = true, + actions = null, + actionsPosition = 'top', +}: TableProps): React.ReactElement => { + + const getRowClassName = (item: T, index: number): string => { + if (typeof rowClassName === 'function') { + return rowClassName(item, index); + } + return rowClassName; + }; + + const getCellClassName = (column: ColumnType): string => { + const alignClass = column.align ? `text-${column.align}` : ''; + return `p-3 ${alignClass} ${column.className || ''}`.trim(); + }; + + const renderTableBody = () => { + if (isLoading) { + return ( + + + + ); + } + + if (data.length === 0) { + return ( + + + {noDataMessage} + + + ); + } + + return data.map((item, rowIndex) => ( + onRowClick && onRowClick(item.id, item)} + > + {columns.map((col) => ( + + {col.render ? col.render(item) : item[col.key] as React.ReactNode} + + ))} + + )); + }; + + const renderHeader = () => { + if (!showHeader || actionsPosition === 'header-replace') return null; + + return ( + + + {columns.map((col) => ( + + + + ))} + + + ); + }; + + const renderActions = () => { + if (!actions) return null; + + return ( +
+ {actions} +
+ ); + }; + + return ( +
+ {(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()} + + {renderHeader()} + {actionsPosition === 'header-replace' && ( + + + + + + )} + + {renderTableBody()} + +
+ {renderActions()} +
+
+ ); +}; + +export default Table; \ No newline at end of file diff --git a/src/components/Td.tsx b/src/components/Td.tsx new file mode 100644 index 0000000..1c90952 --- /dev/null +++ b/src/components/Td.tsx @@ -0,0 +1,22 @@ +import { FC, ReactNode } from 'react' + +interface Props { + text: string, + children?: ReactNode, + dir?: string, +} + +const Td: FC = (props: Props) => { + return ( + + { + props.text ? + props.text + : + props.children + } + + ) +} + +export default Td \ No newline at end of file diff --git a/src/index.css b/src/index.css index 9322ad6..20ad86d 100644 --- a/src/index.css +++ b/src/index.css @@ -44,5 +44,38 @@ textarea::placeholder { @theme { --color-primary: #000000; --color-secondary: #eaecf4; + --color-border: #d0d0d0; --color-description: #888888; + --radius-2.5: 10px; +} + +td { + @apply px-6 py-4 whitespace-nowrap text-xs; +} +.thead { + @apply h-[69px] bg-white text-sm text-[#8C90A3] rounded-3xl overflow-hidden; +} +.tr { + @apply w-full h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-[#F4F5F9]; +} +.action { + @apply h-[74px] bg-white flex items-center px-6; +} + +.rmdp-input { + min-height: 40px; + background-color: white; + border-radius: 12px !important; + border: 1px solid #d0d0d0 !important; + font-size: 12px !important; + width: 100% !important; + margin: 0px; + padding-right: 16px !important; +} +.readOny .rmdp-input { + background-color: #f5f5f5 !important; + border: none !important; +} +.rmdp-container { + width: 100%; } diff --git a/src/langs/fa.json b/src/langs/fa.json index 7f52466..dbb2662 100644 --- a/src/langs/fa.json +++ b/src/langs/fa.json @@ -16,5 +16,12 @@ }, "header": { "search": "جستجو" + }, + "received": { + "title": "دریافتی ها", + "from_date": "از تاریخ", + "to_date": "تا تاریخ", + "all": "همه", + "search": "جستجو" } } diff --git a/src/pages/received/List.tsx b/src/pages/received/List.tsx new file mode 100644 index 0000000..d6be73a --- /dev/null +++ b/src/pages/received/List.tsx @@ -0,0 +1,95 @@ +import Filters, { FilterValues } from '../../components/Filters'; +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Table, { ColumnType } from '../../components/Table'; +import { InfoCircle, More, Refresh2 } from 'iconsax-react'; + +interface Message extends Record { + id: string; + title: string; + sender: string; + date: string; + read: boolean; +} + +const List: FC = () => { + const { t } = useTranslation(); + const [isLoading] = useState(false); + + 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 columns: ColumnType[] = [ + { + key: 'title', + title: 'عنوان پیام', + render: (item) => ( +
+ {!item.read &&
} + {item.title} +
+ ) + }, + { key: 'sender', title: 'فرستنده' }, + { key: 'date', title: 'تاریخ', align: 'center' }, + ]; + + const tableActions = ( +
+ + +
+ ); + + const handleFilterChange = (newFilters: FilterValues) => { + console.log('Applied filters:', newFilters); + }; + + return ( +
+

{t('received.title')}

+ + + + + columns={columns} + data={messageData} + isLoading={isLoading} + showHeader={false} + actions={tableActions} + onRowClick={(id) => console.log(`کلیک روی پیام با شناسه ${id}`)} + noDataMessage={ +
+ +
هیچ پیامی یافت نشد
+
+ } + /> + +
+ ) +} + +export default List diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 7a8f403..a5c9d0f 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,11 +1,14 @@ import { FC } from 'react' +import { Paths } from '@/utils/Paths' import { Routes, Route } from 'react-router-dom' import Home from '@/pages/home/Home' +import ReceivedList from '@/pages/received/List' const AppRouter: FC = () => { return ( } /> + } /> ) } diff --git a/src/shared/Header.tsx b/src/shared/Header.tsx index 82f6e3f..bf29e06 100644 --- a/src/shared/Header.tsx +++ b/src/shared/Header.tsx @@ -33,6 +33,7 @@ const Header: FC = () => {
setOpenSidebar(!openSidebar)} className='xl:hidden block'> diff --git a/src/shared/Main.tsx b/src/shared/Main.tsx index dfd43bd..2ee9d2f 100644 --- a/src/shared/Main.tsx +++ b/src/shared/Main.tsx @@ -1,5 +1,6 @@ import { FC } from 'react' import SideBar from './SideBar' +import AppRouter from '@/router/AppRouter' import Header from './Header' const Main: FC = () => { @@ -7,7 +8,13 @@ const Main: FC = () => {
- +
+
+
+ +
+
+
) }