diff --git a/package-lock.json b/package-lock.json index d8f445f..174da11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,12 @@ "@tanstack/react-query": "^5.90.3", "axios": "^1.12.2", "iconsax-react": "^0.0.8", + "moment-jalaali": "^0.10.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.5.0", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.9.4", "react-spinners": "^0.17.0", "swiper": "^12.0.2", @@ -28,6 +30,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/moment-jalaali": "^0.7.9", "@types/node": "^24.6.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -2105,6 +2108,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/moment-jalaali": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/@types/moment-jalaali/-/moment-jalaali-0.7.9.tgz", + "integrity": "sha512-gsDOoAzRnCfQTbfdlUrCvX6R0wIto6CvwfvV2C3j4qJLK+DEiTK8Rl/xlOCBO9C6qeUfX8oyZ2UfjnXJTOvHSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": ">=2.14.0" + } + }, "node_modules/@types/node": { "version": "24.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", @@ -3537,6 +3550,12 @@ "dev": true, "license": "ISC" }, + "node_modules/jalaali-js": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz", + "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4006,6 +4025,38 @@ "node": ">= 18" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-jalaali": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/moment-jalaali/-/moment-jalaali-0.10.4.tgz", + "integrity": "sha512-/eD0HeyvATznb5iE0G1BHjKRZAFEpJ9ZNUkcHwXhNgt1WJJVVzHD7+uDmqzZWVFLdbGme2gvIXKb3ezDYOXcZA==", + "license": "MIT", + "dependencies": { + "jalaali-js": "^1.2.7", + "moment": "^2.29.4", + "moment-timezone": "^0.5.46" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4250,6 +4301,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-date-object": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz", + "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==", + "license": "MIT" + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -4262,6 +4319,16 @@ "react": "^19.2.0" } }, + "node_modules/react-element-popper": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz", + "integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-infinite-scroll-component": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", @@ -4289,6 +4356,20 @@ "react": ">=16.8.0" } }, + "node_modules/react-multi-date-picker": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", + "integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==", + "license": "MIT", + "dependencies": { + "react-date-object": "^2.1.8", + "react-element-popper": "^2.1.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index 3dbd250..d0fd50a 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "@tanstack/react-query": "^5.90.3", "axios": "^1.12.2", "iconsax-react": "^0.0.8", + "moment-jalaali": "^0.10.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.5.0", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.9.4", "react-spinners": "^0.17.0", "swiper": "^12.0.2", @@ -30,6 +32,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/moment-jalaali": "^0.7.9", "@types/node": "^24.6.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx new file mode 100644 index 0000000..1fc88e7 --- /dev/null +++ b/src/components/DatePicker.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect, type 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) => { + 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/DefaulModal.tsx b/src/components/DefaulModal.tsx new file mode 100644 index 0000000..67729e8 --- /dev/null +++ b/src/components/DefaulModal.tsx @@ -0,0 +1,56 @@ +import { type FC, Fragment, type ReactNode, useEffect } from 'react' +import HeaderModal from './HeaderModal' + +interface Props { + open: boolean, + close: () => void, + children: ReactNode, + isHeader?: boolean, + title_header?: string, + width?: number +} + +const DefaulModal: FC = (props: Props) => { + useEffect(() => { + if (props.open) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = 'auto' + } + }, [props.open]) + + + return ( + + { + props.open && ( + +
+
+
+ + { + props.isHeader && props.title_header && +
+
+ +
+ } + + {props.children} + + +
+
+
+ +
+
+ ) + } +
+ ) +} + +export default DefaulModal \ No newline at end of file diff --git a/src/components/Filters.tsx b/src/components/Filters.tsx new file mode 100644 index 0000000..7302659 --- /dev/null +++ b/src/components/Filters.tsx @@ -0,0 +1,229 @@ +import { type FC, useEffect, useState, type ChangeEvent, useRef } from 'react'; +import { Filter } from 'iconsax-react'; +import DatePicker from './DatePicker'; +import Input from './Input'; +import Select, { type ItemsSelectType } from './Select'; +import DefaulModal from './DefaulModal'; +import moment from 'moment-jalaali' +import Button from './Button'; + +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 = "flex flex-col md:flex-row justify-between items-start md:items-center gap-4", + fieldClassName = "flex flex-col sm:flex-row gap-3 md:gap-4 w-full md:w-auto", + searchField = "search" +}) => { + const [filters, setFilters] = useState({}); + const [isFiltersOpen, setIsFiltersOpen] = useState(false); + const [inputValues, setInputValues] = useState>({}); + const onChangeRef = useRef(onChange); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + if (Object.keys(initialValues).length > 0) { + setFilters(initialValues); + const inputFields: Record = {}; + fields.forEach(field => { + if (field.type === 'input' && initialValues[field.name]) { + inputFields[field.name] = initialValues[field.name] as string; + } + }); + setInputValues(inputFields); + } else { + const defaultValues: FilterValues = {}; + const defaultInputs: Record = {}; + fields.forEach(field => { + if ('defaultValue' in field && field.defaultValue !== undefined) { + defaultValues[field.name] = field.defaultValue; + if (field.type === 'input') { + defaultInputs[field.name] = field.defaultValue; + } + } + }); + + if (Object.keys(defaultValues).length > 0) { + setFilters(defaultValues); + setInputValues(defaultInputs); + onChange(defaultValues); + } + } + }, [fields, initialValues, onChange]); + + useEffect(() => { + const timeouts: Record = {}; + + Object.keys(inputValues).forEach(fieldName => { + timeouts[fieldName] = setTimeout(() => { + setFilters(currentFilters => { + const currentFilter = currentFilters[fieldName]; + const newValue = inputValues[fieldName]; + + if (currentFilter !== newValue) { + const newFilters = { ...currentFilters, [fieldName]: newValue || null }; + onChangeRef.current(newFilters); + return newFilters; + } + return currentFilters; + }); + }, 1000); + }); + + return () => { + Object.values(timeouts).forEach(timeout => clearTimeout(timeout)); + }; + }, [inputValues]); + + const handleChange = (name: string, value: string | null) => { + const newFilters = { ...filters, [name]: value }; + setFilters(newFilters); + onChange(newFilters); + }; + + const handleInputChange = (name: string, event: ChangeEvent) => { + const value = event.target.value; + setInputValues(prev => ({ ...prev, [name]: value })); + }; + + const renderField = (field: FieldType) => { + const currentValue = field.type === 'input' ? inputValues[field.name] : filters[field.name]; + + switch (field.type) { + case 'date': + return ( + handleChange(field.name, moment(value, 'jYYYY/jMM/jDD').format('YYYY-MM-DD'))} + 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 ( +
+
+ + {searchFieldObj && ( +
+ {renderField(searchFieldObj)} +
+ )} +
+ +
+
+ {otherFields.map(renderField)} +
+ {searchFieldObj && ( +
+ {renderField(searchFieldObj)} +
+ )} +
+ + setIsFiltersOpen(false)} + isHeader={true} + title_header="فیلتر کردن نتایج" + > +
+ {otherFields.map(field => ( +
+ +
+ {renderField(field)} +
+
+ ))} +
+
+
+
+
+ ); +}; + +export default Filters; \ No newline at end of file diff --git a/src/components/HeaderModal.tsx b/src/components/HeaderModal.tsx new file mode 100644 index 0000000..1e0c007 --- /dev/null +++ b/src/components/HeaderModal.tsx @@ -0,0 +1,25 @@ +import { type FC } from 'react' +import { CloseCircle } from 'iconsax-react' + +type Props = { + label: string, + close: () => void, +} + +const HeaderModal: FC = (props: Props) => { + return ( +
+
{props.label}
+
+ +
+
+ ) +} + +export default HeaderModal \ No newline at end of file diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 8ec9e87..6d0d353 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' && 'border-0 ps-10 mt-0', props.className ); @@ -84,7 +84,7 @@ 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..6904d92 --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,64 @@ +import type { 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 index 56d691a..6f51a77 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useState } from 'react'; +import React, { Fragment, useState, memo } from 'react'; import DefaultTableSkeleton from '@/components/DefaultTableSkeleton'; import Td from '@/components/Td'; import type { TableProps, RowDataType, ColumnType } from '@/components/types/TableTypes'; @@ -321,7 +321,7 @@ const Table = ({ ); }; -export default Table; +export default memo(Table) as (props: TableProps) => React.ReactElement; /* مثال استفاده با pagination: diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx new file mode 100644 index 0000000..a8e188a --- /dev/null +++ b/src/components/Tabs.tsx @@ -0,0 +1,41 @@ +import { clx } from '@/helpers/utils' +import { type FC } from 'react' + +type TabItem = { + label: string + value: string +} + +type Props = { + items: TabItem[] + activeTab: string + onTabChange: (tab: string) => void +} + +const Tabs: FC = (props) => { + + const { items, activeTab, onTabChange } = props + + + return ( +
+
+ { + items.map((item) => { + return ( +
onTabChange(item.value)} key={item.value} className={clx( + 'h-[32px] flex items-center justify-center w-[122px] cursor-pointer rounded-full', + activeTab === item.value && 'bg-primary' + )}> + {item.label} +
+ ) + }) + } +
+ +
+ ) +} + +export default Tabs \ No newline at end of file diff --git a/src/index.css b/src/index.css index b7c74c0..20b1733 100644 --- a/src/index.css +++ b/src/index.css @@ -49,3 +49,23 @@ textarea::placeholder { tbody tr:nth-child(odd) { background-color: #f5f7fc; } + +.rmdp-input { + min-height: 40px; + background-color: white; + border-radius: 12px !important; + border: 1px solid oklch(0.922 0 0) !important; + font-size: 12px !important; + width: 100% !important; + margin: 0px; + padding-right: 16px !important; + transition: background-color 0.3s ease, border-color 0.3s ease; +} +.readOny .rmdp-input { + background-color: #f5f5f5 !important; + border: none !important; +} + +.rmdp-container { + width: 100%; +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..a757447 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,7 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - - - , + ) diff --git a/src/pages/order/MyOrders.tsx b/src/pages/order/MyOrders.tsx new file mode 100644 index 0000000..c4dc0d3 --- /dev/null +++ b/src/pages/order/MyOrders.tsx @@ -0,0 +1,118 @@ +import Tabs from '@/components/Tabs' +import { useState, type FC } from 'react' +import { TabMyOrdersEnum } from './enum/OrderEnum' +import Filters from '@/components/Filters' +import Button from '@/components/Button' +import { AddSquare } from 'iconsax-react' +import Table from '@/components/Table' + +const MyOrders: FC = () => { + + const [activeTab, setActiveTab] = useState(TabMyOrdersEnum.ALL) + return ( +
+

+ سفارشات من +

+ +
+ setActiveTab(tab as TabMyOrdersEnum)} + /> +
+ +
+ { }} + /> + + +
+ +
+ + + + ) +} + +export default MyOrders \ No newline at end of file diff --git a/src/pages/order/enum/OrderEnum.ts b/src/pages/order/enum/OrderEnum.ts new file mode 100644 index 0000000..ab99192 --- /dev/null +++ b/src/pages/order/enum/OrderEnum.ts @@ -0,0 +1,7 @@ +export const enum TabMyOrdersEnum { + ALL = "all", + IN_PROGRESS = "in_progress", + CONFIRMED = "confirmed", + DELIVERED = "delivered", + CANCELLED = "cancelled", +} diff --git a/src/router/MainRouter.tsx b/src/router/MainRouter.tsx index 99b13c8..ca2db5f 100644 --- a/src/router/MainRouter.tsx +++ b/src/router/MainRouter.tsx @@ -4,6 +4,7 @@ import Home from '../pages/home/Home' import SideBar from '../shared/Sidebar' import Header from '../shared/Header' import { Paths } from '@/config/Paths' +import MyOrders from '@/pages/order/MyOrders' const MainRouter: FC = () => { return ( @@ -17,6 +18,7 @@ const MainRouter: FC = () => { } /> } /> + } /> diff --git a/tsconfig.app.json b/tsconfig.app.json index 51b7e95..3ccd297 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -24,7 +24,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "baseUrl": "./", diff --git a/tsconfig.node.json b/tsconfig.node.json index 2d86ad5..c4363c6 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -20,7 +20,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "baseUrl": "./",